fix: Prevent article editor from resetting content while typing (#14014)
# Pull Request Template ## Description ### Description This PR fixes an issue where the editor would reset content and move the cursor while typing. The issue was caused by a dual debounce setup (400ms + 2500ms) that saved content and then overwrote local state with stale API responses while the user was still typing. ### What changed * Editor now uses local state (`localTitle`, `localContent`) as the source of truth while editing * Vuex store is only used on initial load or navigation * Replaced dual debounce with a single 500ms debounce (fewer API calls) * `UPDATE_ARTICLE` now merges updates instead of replacing the article * Prevents status changes from wiping unsaved content * Removed `updateAsync` for a simpler update flow ### How it works User types → local ref updates immediately (editor reads from this) → 500ms debounce triggers → dispatches `articles/update` → API persists the change → on success: store merges the response (used by other components) → editor remains unaffected (continues using local state) Fixes https://linear.app/chatwoot/issue/CW-6727/better-syncing-of-content-the-editor-randomly-updates-the-content ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Open any Help Center article for editing 2. Type continuously for a few seconds — content should not reset or jump 3. Change article status (publish/archive/draft) while editing — content should remain intact 4. Test on a slow network (use DevTools throttling) — typing should remain smooth ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||||
@@ -27,7 +27,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'saveArticle',
|
'saveArticle',
|
||||||
'saveArticleAsync',
|
|
||||||
'goBack',
|
'goBack',
|
||||||
'setAuthor',
|
'setAuthor',
|
||||||
'setCategory',
|
'setCategory',
|
||||||
@@ -39,42 +38,37 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const isNewArticle = computed(() => !props.article?.id);
|
const isNewArticle = computed(() => !props.article?.id);
|
||||||
|
|
||||||
const saveAndSync = value => {
|
const localTitle = ref(props.article?.title ?? '');
|
||||||
emit('saveArticle', value);
|
const localContent = ref(props.article?.content ?? '');
|
||||||
};
|
|
||||||
|
|
||||||
// this will only send the data to the backend
|
// Sync local state when navigating to a different article or on initial fetch
|
||||||
// but will not update the local state preventing unnecessary re-renders
|
watch(
|
||||||
// since the data is already saved and we keep the editor text as the source of truth
|
() => props.article?.id,
|
||||||
const quickSave = debounce(
|
newId => {
|
||||||
value => emit('saveArticleAsync', value),
|
if (newId) {
|
||||||
400,
|
localTitle.value = props.article?.title ?? '';
|
||||||
false
|
localContent.value = props.article?.content ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||||
// so we can save the data to the backend and retrieve the updated data
|
|
||||||
// this will update the local state with response data
|
|
||||||
// Only use to save for existing articles
|
|
||||||
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
|
|
||||||
|
|
||||||
const handleSave = value => {
|
const handleSave = value => {
|
||||||
if (isNewArticle.value) return;
|
if (isNewArticle.value) return;
|
||||||
quickSave(value);
|
debouncedSave(value);
|
||||||
saveAndSyncDebounced(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const articleTitle = computed({
|
const articleTitle = computed({
|
||||||
get: () => props.article.title,
|
get: () => localTitle.value,
|
||||||
set: value => {
|
set: value => {
|
||||||
|
localTitle.value = value;
|
||||||
handleSave({ title: value });
|
handleSave({ title: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const localContent = ref(props.article.content || '');
|
|
||||||
|
|
||||||
const articleContent = computed({
|
const articleContent = computed({
|
||||||
get: () => props.article.content,
|
get: () => localContent.value,
|
||||||
set: content => {
|
set: content => {
|
||||||
localContent.value = content;
|
localContent.value = content;
|
||||||
handleSave({ content });
|
handleSave({ content });
|
||||||
@@ -132,7 +126,7 @@ const handleCreateArticle = event => {
|
|||||||
/>
|
/>
|
||||||
<ArticleEditorControls
|
<ArticleEditorControls
|
||||||
:article="article"
|
:article="article"
|
||||||
@save-article="saveAndSync"
|
@save-article="values => emit('saveArticle', values)"
|
||||||
@set-author="setAuthorId"
|
@set-author="setAuthorId"
|
||||||
@set-category="setCategoryId"
|
@set-category="setCategoryId"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,11 +40,10 @@ const articleLink = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveArticle = async ({ ...values }, isAsync = false) => {
|
const saveArticle = async ({ ...values }) => {
|
||||||
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
|
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
await store.dispatch(actionToDispatch, {
|
await store.dispatch('articles/update', {
|
||||||
portalSlug,
|
portalSlug,
|
||||||
articleId: articleSlug,
|
articleId: articleSlug,
|
||||||
...values,
|
...values,
|
||||||
@@ -62,10 +61,6 @@ const saveArticle = async ({ ...values }, isAsync = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveArticleAsync = async ({ ...values }) => {
|
|
||||||
saveArticle({ ...values }, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCategoryArticles = computed(() => {
|
const isCategoryArticles = computed(() => {
|
||||||
return (
|
return (
|
||||||
route.name === 'portals_categories_articles_index' ||
|
route.name === 'portals_categories_articles_index' ||
|
||||||
@@ -112,7 +107,6 @@ onMounted(fetchArticleDetails);
|
|||||||
:is-updating="isUpdating"
|
:is-updating="isUpdating"
|
||||||
:is-saved="isSaved"
|
:is-saved="isSaved"
|
||||||
@save-article="saveArticle"
|
@save-article="saveArticle"
|
||||||
@save-article-async="saveArticleAsync"
|
|
||||||
@preview-article="previewArticle"
|
@preview-article="previewArticle"
|
||||||
@go-back="goBackToArticles"
|
@go-back="goBackToArticles"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -69,30 +69,9 @@ 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 }) => {
|
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||||
uiFlags: {
|
uiFlags: { isUpdating: true },
|
||||||
isUpdating: true,
|
|
||||||
},
|
|
||||||
articleId,
|
articleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,9 +89,7 @@ export const actions = {
|
|||||||
return throwErrorMessage(error);
|
return throwErrorMessage(error);
|
||||||
} finally {
|
} finally {
|
||||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||||
uiFlags: {
|
uiFlags: { isUpdating: false },
|
||||||
isUpdating: false,
|
|
||||||
},
|
|
||||||
articleId,
|
articleId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,14 +79,12 @@ export const mutations = {
|
|||||||
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
|
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
|
||||||
const articleId = updatedArticle.id;
|
const articleId = updatedArticle.id;
|
||||||
if ($state.articles.byId[articleId]) {
|
if ($state.articles.byId[articleId]) {
|
||||||
// Preserve the original position
|
const existing = $state.articles.byId[articleId];
|
||||||
const originalPosition = $state.articles.byId[articleId].position;
|
|
||||||
|
|
||||||
// Update the article, keeping the original position
|
|
||||||
// This is not moved out of the original position when we update the article
|
|
||||||
$state.articles.byId[articleId] = {
|
$state.articles.byId[articleId] = {
|
||||||
|
...existing,
|
||||||
...updatedArticle,
|
...updatedArticle,
|
||||||
position: originalPosition,
|
position: existing.position,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user