mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
feat: async update of article [CW-3721] (#10435)
### The problem Writing in the text editor can be very frustrating, the reason is that the editor had a debounced save method which would push the article to the backend and update the current state. This however is a bad idea, since the can take anywhere between 100-300ms depending on network conditions. While this would be in progress, the article is still being edited by the user. So at the end of the network request, the state returned from the backend and the current state in the editor is diverged. But since the update happens anyway, the editor would prepend older context. ``` Time --> User Action: [Edit 1] ---> [Edit 2] ---> [Edit 3] Backend Save: Save Req (Edit 1) ----> Response (Edit 1) Resulting Editor State: [Edit 3] + [Edit 1] (Outdated state prepended) ``` ### The solution The solution is to unbind the article from the backend state, ensuring that the article editor is the source of truth and ignoring the responses. This pull request does this by adding an asynchronous save functionality. The changes include adding a new `saveArticleAsync` event and ensuring that the local state is not updated unnecessarily during asynchronous saves. ``` Time --> User Action: [Edit 1] ---> [Edit 2] ---> [Edit 3] Backend Save: Save Req (Edit 1) ----> Response (ignored) Resulting Editor State: [Edit 3] (Consistent and up-to-date) ``` Added the following two debounced methods These complementary debounce methods prevent unnecessary re-renders while ensuring backend is in sync. `saveArticleAsync` preserves the editor as the source of truth, while `saveArticle` manages periodic state updates from the backend with a delay large enough to safely assume that the user has stopped typing Method | Delay | Behavior -- | -- | -- `saveArticleAsync` | 400ms | Sends data to backend and ignores the response `saveArticle` | 2.5s | Sends data and updates local state with the backend response ### How to test 1. Remove the following linedc042f6ddc/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue (L64)1. Update the latency here to 400 (P.S. the diff shows the latency to be 600, but that was added as a stop-gap solution)dc042f6ddc/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue (L51)1. Set the browser network latency to Slow 3G or 3G 1. Start writing on the editor, try fixing typos with backspace or moving around with the cursor --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'saveArticleAsync',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
@@ -35,19 +36,37 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
|
||||
const saveAndSync = value => {
|
||||
emit('saveArticle', value);
|
||||
};
|
||||
|
||||
// this will only send the data to the backend
|
||||
// but will not update the local state preventing unnecessary re-renders
|
||||
// since the data is already saved and we keep the editor text as the source of truth
|
||||
const quickSave = debounce(
|
||||
value => emit('saveArticleAsync', value),
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
||||
// so we can save the data to the backend and retrieve the updated data
|
||||
// this will update the local state with response data
|
||||
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
set: value => {
|
||||
saveArticle({ title: value });
|
||||
quickSave({ title: value });
|
||||
saveAndSyncDebounced({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => props.article.content,
|
||||
set: content => {
|
||||
saveArticle({ content });
|
||||
quickSave({ content });
|
||||
saveAndSyncDebounced({ content });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +112,7 @@ const previewArticle = () => {
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
:article="article"
|
||||
@save-article="saveArticle"
|
||||
@save-article="saveAndSync"
|
||||
@set-author="setAuthorId"
|
||||
@set-category="setCategoryId"
|
||||
/>
|
||||
|
||||
@@ -34,10 +34,11 @@ const portalLink = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const saveArticle = async ({ ...values }) => {
|
||||
const saveArticle = async ({ ...values }, isAsync = false) => {
|
||||
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
await store.dispatch(actionToDispatch, {
|
||||
portalSlug,
|
||||
articleId: articleSlug,
|
||||
...values,
|
||||
@@ -55,6 +56,10 @@ const saveArticle = async ({ ...values }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveArticleAsync = async ({ ...values }) => {
|
||||
saveArticle({ ...values }, true);
|
||||
};
|
||||
|
||||
const isCategoryArticles = computed(() => {
|
||||
return (
|
||||
route.name === 'portals_categories_articles_index' ||
|
||||
@@ -92,9 +97,7 @@ const previewArticle = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchArticleDetails();
|
||||
});
|
||||
onMounted(fetchArticleDetails);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,6 +106,7 @@ onMounted(() => {
|
||||
:is-updating="isUpdating"
|
||||
:is-saved="isSaved"
|
||||
@save-article="saveArticle"
|
||||
@save-article-async="saveArticleAsync"
|
||||
@preview-article="previewArticle"
|
||||
@go-back="goBackToArticles"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,25 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateAsync: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: { isUpdating: true },
|
||||
articleId,
|
||||
});
|
||||
|
||||
try {
|
||||
await articlesAPI.updateArticle({ portalSlug, articleId, articleObj });
|
||||
return articleId;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: { isUpdating: false },
|
||||
articleId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: {
|
||||
|
||||
Reference in New Issue
Block a user