feat: stage edits to published articles as drafts (#14842)

This commit is contained in:
Sivin Varghese
2026-07-16 12:44:10 +05:30
committed by GitHub
parent f1a07657e9
commit 0fccb7dacd
21 changed files with 1280 additions and 59 deletions
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -7,7 +7,9 @@ import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
getArticleStatus,
} from 'dashboard/helper/portalHelper';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
@@ -53,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
hasPendingChanges: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
@@ -68,12 +74,16 @@ const emit = defineEmits([
'articleAction',
'toggleSelect',
'hover',
'draftResolved',
'draftFailed',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
const draftItems = props.hasPendingChanges
? [
{
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
},
]
: [];
return [...statusItems, ...draftItems, commonItems.delete];
});
const statusTextColor = computed(() => {
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
// Un-publishing an article with staged edits — confirm apply/discard first;
// the popover applies the chosen status itself.
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
emit('articleAction', { action, value, id: props.id });
};
@@ -184,6 +211,18 @@ const handleClick = id => {
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="hasPendingChanges"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
)
"
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
@@ -204,9 +243,15 @@ const handleClick = id => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
class="mt-1 end-0 top-full w-40"
@action="handleArticleAction($event)"
/>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="id"
@resolved="emit('draftResolved', $event)"
@failed="emit('draftFailed', $event)"
/>
</div>
</div>
</div>
@@ -0,0 +1,173 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
renderInlineDiff,
buildDiffBlocks,
} from 'dashboard/helper/articleDiffHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const isOpen = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const liveTitle = computed(() => props.article?.title ?? '');
const liveContent = computed(() => props.article?.content ?? '');
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
const draftContent = computed(
() => props.article?.draftContent ?? liveContent.value
);
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
const titleDiff = computed(() =>
renderInlineDiff(liveTitle.value, draftTitle.value)
);
const contentBlocks = computed(() =>
buildDiffBlocks(liveContent.value, draftContent.value)
);
const contentChanged = computed(() =>
contentBlocks.value.some(block => block.type !== 'equal')
);
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
// unsized columns so they don't collapse.
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
const DEFAULT_COL_WIDTH = 50;
const applyColumnWidths = (html, widths) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.body.querySelector('table');
if (!table) return html;
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
const colgroup = doc.createElement('colgroup');
sized.forEach(width => {
const col = doc.createElement('col');
col.style.width = `${width}px`;
colgroup.appendChild(col);
});
table.insertBefore(colgroup, table.firstChild);
table.style.tableLayout = 'fixed';
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
return doc.body.innerHTML;
};
const renderMarkdown = markdown => {
if (!markdown) return '';
const html = new MessageFormatter(markdown).formattedMessage;
const match = markdown.match(COLWIDTHS_RE);
return match
? applyColumnWidths(html, match[1].split(',').map(Number))
: html;
};
const blockClass = type => {
if (type === 'added') {
return 'border-n-teal-9 bg-n-teal-2';
}
if (type === 'removed') {
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
}
return 'border-transparent';
};
const close = () => {
isOpen.value = false;
};
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
</script>
<template>
<TeleportWithDirection to="body">
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<aside
v-if="isOpen"
v-on-click-outside="dismissOnClickOutside"
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
>
<header
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
</h3>
</div>
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="sm"
class="shrink-0 hover:text-n-slate-11"
@click="close"
/>
</header>
<div
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="titleChanged"
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
>
<span
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
>
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
</span>
<h1
class="text-lg font-semibold leading-snug text-n-slate-12"
v-html="titleDiff"
/>
</div>
<div
v-if="contentChanged"
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
>
<div
v-for="(block, index) in contentBlocks"
:key="index"
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
:class="blockClass(block.type)"
v-html="renderMarkdown(block.md)"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</aside>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
const props = defineProps({
article: {
@@ -38,32 +39,75 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
// falling back to the live value.
const effectiveTitle = () =>
props.article?.draftTitle ?? props.article?.title ?? '';
const effectiveContent = () =>
props.article?.draftContent ?? props.article?.content ?? '';
// Sync local state when navigating to a different article or on initial fetch
const hasPendingChanges = computed(
() => props.article?.draftTitle != null || props.article?.draftContent != null
);
const localTitle = ref(effectiveTitle());
const localContent = ref(effectiveContent());
const isDiffPanelOpen = ref(false);
// Autosave 500ms after the last edit. It sends both title and content so an
// edit to one never drops a recent edit to the other. `stop` cancels a queued
// save; `isPending` tells the header to wait before allowing a publish.
const {
isPending: isSaving,
start: debouncedSave,
stop: cancelSave,
} = useTimeoutFn(
() =>
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
}),
500,
{ immediate: false }
);
const syncLocalState = () => {
cancelSave();
localTitle.value = effectiveTitle();
localContent.value = effectiveContent();
};
// Reseed on article switch or once a draft is published/discarded; close the
// diff panel in the latter case since there's nothing left to compare.
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
[() => props.article?.id, hasPendingChanges],
([id, pending], [prevId, prevPending]) => {
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
if (prevPending && !pending) isDiffPanelOpen.value = false;
}
);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
const scheduleSave = () => {
if (isNewArticle.value) return;
debouncedSave(value);
debouncedSave();
};
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
onBeforeUnmount(() => {
if (isNewArticle.value || !isSaving.value) return;
cancelSave();
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
});
});
const articleTitle = computed({
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
scheduleSave();
},
});
@@ -71,7 +115,7 @@ const articleContent = computed({
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
scheduleSave();
},
});
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
:pending-changes="hasPendingChanges"
:is-saving="isSaving"
@go-back="onClickGoBack"
@preview-article="previewArticle"
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
/>
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
@@ -1,8 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
const props = defineProps({
isUpdating: {
@@ -35,9 +36,17 @@ const props = defineProps({
type: Number,
default: 0,
},
pendingChanges: {
type: Boolean,
default: false,
},
isSaving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
const { t } = useI18n();
const store = useStore();
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
// Per-article update flag the store already maintains.
const articleUiFlags = useMapGetter('articles/uiFlags');
const isUpdatingArticle = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
const blockedWhileSaving = () => {
if (!props.isSaving && !isUpdatingArticle.value) return false;
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
return true;
};
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
const hasPendingChanges = computed(
() => isPublished.value && props.pendingChanges
);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const items = statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
icon,
};
});
if (hasPendingChanges.value) {
items.push({
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
});
}
return items;
});
const statusText = computed(() =>
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
// update; omit it for a plain status change.
const performStatusUpdate = async (value, draftAction) => {
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
portalSlug,
articleId: props.articleId,
status,
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
} finally {
isArticlePublishing.value = false;
}
};
const updateArticleStatus = ({ value }) => {
showArticleActionMenu.value = false;
// Leaving published with unsaved draft edits — ask whether to apply or discard
// first; the popover applies the status itself once resolved.
if (hasPendingChanges.value) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
performStatusUpdate(value);
};
const publishDraftChanges = async () => {
isArticlePublishing.value = true;
const { portalSlug } = route.params;
try {
await store.dispatch('articles/publishDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
} finally {
isArticlePublishing.value = false;
}
};
const discardDraftChanges = async () => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
);
}
};
const onPrimaryAction = () => {
if (blockedWhileSaving()) return;
if (hasPendingChanges.value) {
publishDraftChanges();
} else if (props.pendingChanges) {
// Promote leftover draft edits on publish instead of republishing stale content.
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
} else {
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
}
};
const onMenuAction = event => {
showArticleActionMenu.value = false;
// Don't resolve a draft while an autosave is still in flight — it could land
// after and recreate the draft we just discarded/applied.
if (blockedWhileSaving()) return;
if (event.action === 'discard-draft') {
discardDraftChanges();
} else {
updateArticleStatus(event);
}
};
// The popover applies the draft + status itself; we just surface the outcome.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
};
const onDraftFailed = error => {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
</script>
<template>
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<button
v-if="hasPendingChanges"
type="button"
data-diff-toggle
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
@click="emit('showDiff')"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
</button>
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<div class="relative flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
/>
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
:label="
hasPendingChanges
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
isArticlePublishing ||
(isPublished && !hasPendingChanges)
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
@click="onPrimaryAction"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
@action="onMenuAction($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="articleId"
@resolved="onDraftResolved"
@failed="onDraftFailed"
/>
</div>
</div>
</div>
@@ -0,0 +1,132 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { onKeyStroke } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
articleId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['resolved', 'failed']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isOpen = ref(false);
const requestedStatus = ref(null);
// Which button is in flight, so only that one shows the spinner.
const activeAction = ref(null);
const articleUiFlags = useMapGetter('articles/uiFlags');
const isLoading = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Open the confirmation for a target status; resolving it also applies that status.
const open = status => {
requestedStatus.value = status;
activeAction.value = null;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
// Don't let a click-outside or Escape dismiss the popover mid-action.
const dismiss = () => {
if (!isLoading.value) close();
};
const resolve = async draftAction => {
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
try {
await store.dispatch(`articles/${draftAction}`, {
portalSlug: route.params.portalSlug,
articleId: props.articleId,
status: requestedStatus.value,
});
emit('resolved', requestedStatus.value);
close();
} catch (error) {
emit('failed', error);
}
};
const onApply = () => resolve('publishDraft');
const onDiscard = () => resolve('discardDraft');
onKeyStroke('Escape', () => {
if (isOpen.value) dismiss();
});
defineExpose({ open, close });
</script>
<template>
<div
v-show="isOpen"
v-on-click-outside="dismiss"
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
>
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
)
}}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="xs"
class="shrink-0 -me-1 -mt-1"
:disabled="isLoading"
@click="close"
/>
</div>
<div class="flex items-center justify-between gap-2">
<Button
type="button"
variant="faded"
color="ruby"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'discard'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
"
@click="onDiscard"
/>
<Button
type="button"
color="blue"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'apply'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
"
@click="onApply"
/>
</div>
</div>
</template>
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
getArticleStatus,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
});
};
const refreshArticleMeta = async () => {
await updateArticlesMeta();
await updatePortalMeta();
};
// The card's pending-changes popover applies the status itself; surface the result.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
refreshArticleMeta();
};
const onDraftFailed = error => {
useAlert(
error?.message ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else if (action === 'discard-draft') {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: id,
});
useAlert(
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
);
} else {
await store.dispatch('articles/update', {
portalSlug,
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateArticlesMeta();
await updatePortalMeta();
await refreshArticleMeta();
} catch (error) {
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
const fallbackMessage =
{
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
'discard-draft': t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
),
}[action] ?? getStatusMessage(status, false);
useAlert(error?.message || fallbackMessage);
}
};
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
:has-pending-changes="
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@draft-resolved="onDraftResolved"
@draft-failed="onDraftFailed"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
@@ -60,6 +65,7 @@ const emit = defineEmits([
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
};
const bulkUpdateStatus = async status => {
const selectedIds = [...selectedArticleIds.value];
const { portalSlug } = route.params;
const pendingIds = props.articles
.filter(
article =>
selectedIds.includes(article.id) &&
article.status === ARTICLE_STATUSES.PUBLISHED &&
hasPendingChanges(article)
)
.map(article => article.id);
// Publish promotes each pending draft; other status changes skip them.
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
const draftIds = isPublishing ? pendingIds : [];
const skippedCount = isPublishing ? 0 : pendingIds.length;
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
if (!articleIds.length && !draftIds.length) {
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
return;
}
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
if (articleIds.length) {
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
}
await Promise.all(
draftIds.map(articleId =>
store.dispatch('articles/publishDraft', { portalSlug, articleId })
)
);
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
if (skippedCount) {
useAlert(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
);
}
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')