From 0f659224a7418c7474a01b9280ac0915080fadda Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 22 Nov 2024 09:08:08 +0530 Subject: [PATCH] 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 line https://github.com/chatwoot/chatwoot/blob/dc042f6ddcb4e108795ff83bb57f8d92c6a2b90a/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) https://github.com/chatwoot/chatwoot/blob/dc042f6ddcb4e108795ff83bb57f8d92c6a2b90a/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 --- .../Pages/ArticleEditorPage/ArticleEditor.vue | 27 ++++++++++++++++--- .../pages/PortalsArticlesEditPage.vue | 14 ++++++---- .../modules/helpCenterArticles/actions.js | 19 +++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index f15135737..f5b925fbe 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -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 = () => { /> diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue index 736b34096..3bb868a58 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue @@ -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);