mirror of
https://github.com/Nutomic/ibis.git
synced 2024-12-23 08:51:23 +00:00
Handle conflicts in frontend
This commit is contained in:
parent
8c03ec72b1
commit
a15a42b977
9 changed files with 75 additions and 32 deletions
|
@ -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`).
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
@ -59,7 +59,8 @@ create table edit (
|
|||
);
|
||||
|
||||
create table conflict (
|
||||
id uuid primary key,
|
||||
id serial primary key,
|
||||
hash uuid not null,
|
||||
diff text not null,
|
||||
summary text not null,
|
||||
creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
|
||||
|
|
|
@ -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 form = DbConflictForm {
|
||||
id: EditVersion::new(&patch.to_string()),
|
||||
hash: EditVersion::new(&patch.to_string()),
|
||||
diff: patch.to_string(),
|
||||
summary: edit_form.summary.clone(),
|
||||
creator_id: user.local_user.id,
|
||||
|
|
|
@ -23,7 +23,8 @@ use std::sync::Mutex;
|
|||
#[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))]
|
||||
pub struct DbConflict {
|
||||
pub id: EditVersion,
|
||||
pub id: i32,
|
||||
pub hash: EditVersion,
|
||||
pub diff: String,
|
||||
pub summary: String,
|
||||
pub creator_id: i32,
|
||||
|
@ -34,7 +35,7 @@ pub struct DbConflict {
|
|||
#[derive(Debug, Clone, Insertable)]
|
||||
#[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg))]
|
||||
pub struct DbConflictForm {
|
||||
pub id: EditVersion,
|
||||
pub hash: EditVersion,
|
||||
pub diff: String,
|
||||
pub summary: String,
|
||||
pub creator_id: i32,
|
||||
|
@ -58,7 +59,7 @@ impl DbConflict {
|
|||
}
|
||||
|
||||
/// 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();
|
||||
Ok(delete(conflict::table.find(id)).get_result(conn.deref_mut())?)
|
||||
}
|
||||
|
@ -88,14 +89,16 @@ impl DbConflict {
|
|||
data,
|
||||
)
|
||||
.await?;
|
||||
DbConflict::delete(self.id.clone(), &data.db_connection)?;
|
||||
DbConflict::delete(self.id, &data.db_connection)?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(three_way_merge) => {
|
||||
// there is a merge conflict, user needs to do three-way-merge
|
||||
Ok(Some(ApiConflict {
|
||||
id: self.id.clone(),
|
||||
id: self.id,
|
||||
hash: self.hash.clone(),
|
||||
three_way_merge,
|
||||
summary: self.summary.clone(),
|
||||
article_id: original_article.id,
|
||||
previous_version_id: original_article
|
||||
.latest_edit_version(&data.db_connection)?,
|
||||
|
|
|
@ -14,7 +14,8 @@ diesel::table! {
|
|||
|
||||
diesel::table! {
|
||||
conflict (id) {
|
||||
id -> Uuid,
|
||||
id -> Int4,
|
||||
hash -> Uuid,
|
||||
diff -> Text,
|
||||
summary -> Text,
|
||||
creator_id -> Int4,
|
||||
|
|
|
@ -75,7 +75,7 @@ impl ActivityHandler for RejectEdit {
|
|||
let article = self.object.object.dereference(data).await?;
|
||||
let creator = self.object.attributed_to.dereference(data).await?;
|
||||
let form = DbConflictForm {
|
||||
id: EditVersion::new(&self.object.content),
|
||||
hash: EditVersion::new(&self.object.content),
|
||||
diff: self.object.content,
|
||||
summary: self.object.summary,
|
||||
creator_id: creator.id,
|
||||
|
|
|
@ -187,7 +187,7 @@ pub struct EditArticleData {
|
|||
/// [ApiConflict.previous_version]
|
||||
pub previous_version_id: EditVersion,
|
||||
/// 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)]
|
||||
|
@ -213,8 +213,10 @@ pub struct ResolveObject {
|
|||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiConflict {
|
||||
pub id: EditVersion,
|
||||
pub id: i32,
|
||||
pub hash: EditVersion,
|
||||
pub three_way_merge: String,
|
||||
pub summary: String,
|
||||
pub article_id: i32,
|
||||
pub previous_version_id: EditVersion,
|
||||
}
|
||||
|
|
|
@ -1,41 +1,72 @@
|
|||
use crate::common::{ArticleView, EditArticleData};
|
||||
use crate::common::{ApiConflict, ArticleView, EditArticleData};
|
||||
use crate::frontend::app::GlobalState;
|
||||
use crate::frontend::article_title;
|
||||
use crate::frontend::components::article_nav::ArticleNav;
|
||||
use crate::frontend::pages::article_resource;
|
||||
use leptos::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum EditResponse {
|
||||
None,
|
||||
Success,
|
||||
Conflict(ApiConflict),
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn EditArticle() -> impl IntoView {
|
||||
let article = article_resource();
|
||||
|
||||
let (text, set_text) = 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 (wait_for_response, set_wait_for_response) = create_signal(false);
|
||||
let button_is_disabled =
|
||||
Signal::derive(move || wait_for_response.get() || summary.get().is_empty());
|
||||
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 summary = summary.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 {
|
||||
set_edit_error.update(|e| *e = None);
|
||||
let form = EditArticleData {
|
||||
article_id: article.article.id,
|
||||
new_text,
|
||||
summary,
|
||||
previous_version_id: article.latest_version,
|
||||
resolve_conflict_id: None,
|
||||
previous_version_id,
|
||||
resolve_conflict_id,
|
||||
};
|
||||
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);
|
||||
match res {
|
||||
Ok(_res) => {
|
||||
set_edit_response.update(|v| *v = Some(()));
|
||||
set_edit_error.update(|e| *e = None);
|
||||
Ok(Some(conflict)) => {
|
||||
set_edit_response.update(|v| *v = EditResponse::Conflict(conflict));
|
||||
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) => {
|
||||
let msg = err.0.to_string();
|
||||
|
@ -50,16 +81,27 @@ pub fn EditArticle() -> impl IntoView {
|
|||
view! {
|
||||
<ArticleNav article=article/>
|
||||
<Show
|
||||
when=move || edit_response.get().is_some()
|
||||
when=move || edit_response.get() == EditResponse::Success
|
||||
fallback=move || {
|
||||
view! {
|
||||
<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_text.set(article.article.text.clone());
|
||||
view! {
|
||||
<div class="item-view">
|
||||
<h1>{article_title(&article.article)}</h1>
|
||||
{move || {
|
||||
edit_error
|
||||
.get()
|
||||
.map(|err| {
|
||||
view! { <p style="color:red;">{err}</p> }
|
||||
})
|
||||
}}
|
||||
<textarea on:keyup=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_text.update(|p| *p = val);
|
||||
|
@ -67,22 +109,16 @@ pub fn EditArticle() -> impl IntoView {
|
|||
{article.article.text.clone()}
|
||||
</textarea>
|
||||
</div>
|
||||
{move || {
|
||||
edit_error
|
||||
.get()
|
||||
.map(|err| {
|
||||
view! { <p style="color:red;">{err}</p> }
|
||||
})
|
||||
}}
|
||||
<input type="text"
|
||||
placeholder="Summary"
|
||||
value={summary.get_untracked()}
|
||||
on:keyup=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_summary.update(|p| *p = val);
|
||||
}/>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
}
|
||||
|
|
|
@ -446,7 +446,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
|
|||
new_text: "aaaa\n".to_string(),
|
||||
summary: "summary".to_string(),
|
||||
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?;
|
||||
assert_eq!(edit_form.new_text, edit_res.article.text);
|
||||
|
|
Loading…
Reference in a new issue