545 lines
17 KiB
Vue
545 lines
17 KiB
Vue
<script setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { OnClickOutside } from '@vueuse/components';
|
|
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
|
import { useConfig } from 'dashboard/composables/useConfig';
|
|
import { debounce } from '@chatwoot/utils';
|
|
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';
|
|
|
|
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
|
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
|
import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue';
|
|
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
|
import Button from 'dashboard/components-next/button/Button.vue';
|
|
import Input from 'dashboard/components-next/input/Input.vue';
|
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
|
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
|
|
|
const props = defineProps({
|
|
articles: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
categories: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
allowedLocales: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
portalName: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
meta: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
isCategoryArticles: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
'pageChange',
|
|
'fetchPortal',
|
|
'refreshArticles',
|
|
'search',
|
|
]);
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const store = useStore();
|
|
const { t } = useI18n();
|
|
|
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
|
const isFetching = useMapGetter('articles/isFetching');
|
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
const isFeatureEnabledonAccount = useMapGetter(
|
|
'accounts/isFeatureEnabledonAccount'
|
|
);
|
|
|
|
const selectedArticleIds = ref(new Set());
|
|
const isArticleDragging = ref(false);
|
|
const deleteConfirmDialogRef = ref(null);
|
|
const isCategoryMenuOpen = ref(false);
|
|
const searchQuery = ref(route.query.search || '');
|
|
|
|
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
|
|
|
const { isEnterprise } = useConfig();
|
|
|
|
const isTranslationAvailable = computed(
|
|
() =>
|
|
isEnterprise &&
|
|
isFeatureEnabledonAccount.value(
|
|
currentAccountId.value,
|
|
FEATURE_FLAGS.CAPTAIN_TASKS
|
|
)
|
|
);
|
|
|
|
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
|
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
|
|
|
const selectAllLabel = computed(() => {
|
|
if (!visibleArticleIds.value.length) return '';
|
|
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
|
count: visibleArticleIds.value.length,
|
|
});
|
|
});
|
|
|
|
const selectedCountLabel = computed(() =>
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
|
count: selectedArticleIds.value.size,
|
|
})
|
|
);
|
|
|
|
const bulkTranslateDialogRef = ref(null);
|
|
|
|
const hasNoArticles = computed(
|
|
() => !isFetching.value && !props.articles.length
|
|
);
|
|
|
|
const isLoading = computed(() => isFetching.value || isSwitchingPortal.value);
|
|
|
|
const totalArticlesCount = computed(() => props.meta.allArticlesCount);
|
|
|
|
const hasNoArticlesInPortal = computed(
|
|
() => totalArticlesCount.value === 0 && !props.isCategoryArticles
|
|
);
|
|
|
|
const shouldShowPaginationFooter = computed(() => {
|
|
return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value);
|
|
});
|
|
|
|
const updateRoute = newParams => {
|
|
const { portalSlug, locale, tab, categorySlug } = route.params;
|
|
router.push({
|
|
name: 'portals_articles_index',
|
|
params: {
|
|
portalSlug,
|
|
locale: newParams.locale ?? locale,
|
|
tab: newParams.tab ?? tab,
|
|
categorySlug: newParams.categorySlug ?? categorySlug,
|
|
...newParams,
|
|
},
|
|
query: route.query,
|
|
});
|
|
};
|
|
|
|
const articlesCount = computed(() => {
|
|
const { tab } = route.params;
|
|
const { meta } = props;
|
|
const countMap = {
|
|
'': meta.articlesCount,
|
|
mine: meta.mineArticlesCount,
|
|
draft: meta.draftArticlesCount,
|
|
archived: meta.archivedArticlesCount,
|
|
};
|
|
return Number(countMap[tab] || countMap['']);
|
|
});
|
|
|
|
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
|
|
|
|
const showArticleHeaderControls = computed(
|
|
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
|
);
|
|
|
|
const showCategoryHeaderControls = computed(
|
|
() => props.isCategoryArticles && !isSwitchingPortal.value
|
|
);
|
|
|
|
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
|
|
|
const getEmptyStateText = type => {
|
|
if (isSearching.value) {
|
|
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
|
}
|
|
if (props.isCategoryArticles) {
|
|
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
|
}
|
|
const tabName = route.params.tab?.toUpperCase() || 'ALL';
|
|
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`);
|
|
};
|
|
|
|
const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE'));
|
|
const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE'));
|
|
|
|
const handleTabChange = tab =>
|
|
updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value });
|
|
|
|
const handleCategoryAction = value =>
|
|
updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value });
|
|
|
|
const handleLocaleAction = value => {
|
|
updateRoute({ locale: value, categorySlug: '' });
|
|
emit('fetchPortal', value);
|
|
};
|
|
const handlePageChange = page => emit('pageChange', page);
|
|
|
|
const navigateToNewArticlePage = () => {
|
|
const { categorySlug, locale } = route.params;
|
|
router.push({
|
|
name: props.isCategoryArticles
|
|
? 'portals_categories_articles_new'
|
|
: 'portals_articles_new',
|
|
params: { categorySlug, locale },
|
|
});
|
|
};
|
|
|
|
const handleToggleSelect = articleId => {
|
|
const newSet = new Set(selectedArticleIds.value);
|
|
if (newSet.has(articleId)) {
|
|
newSet.delete(articleId);
|
|
} else {
|
|
newSet.add(articleId);
|
|
}
|
|
selectedArticleIds.value = newSet;
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
selectedArticleIds.value = new Set();
|
|
};
|
|
|
|
const handleTranslateArticle = articleId => {
|
|
selectedArticleIds.value = new Set([articleId]);
|
|
bulkTranslateDialogRef.value?.dialogRef?.open();
|
|
};
|
|
|
|
const openTranslateDialog = () => {
|
|
bulkTranslateDialogRef.value?.dialogRef?.open();
|
|
};
|
|
|
|
const onBulkActionSuccess = message => {
|
|
useAlert(message);
|
|
clearSelection();
|
|
emit('refreshArticles');
|
|
};
|
|
|
|
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 {
|
|
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')
|
|
);
|
|
}
|
|
};
|
|
|
|
const categoryMenuItems = computed(() =>
|
|
props.categories.map(category => ({
|
|
label: category.name,
|
|
value: category.id,
|
|
action: 'move',
|
|
emoji: category.icon,
|
|
iconColor: category.icon_color,
|
|
}))
|
|
);
|
|
|
|
const handleBulkUpdateCategory = async ({ value }) => {
|
|
isCategoryMenuOpen.value = false;
|
|
try {
|
|
await articlesAPI.bulkUpdateCategory({
|
|
portalSlug: route.params.portalSlug,
|
|
articleIds: [...selectedArticleIds.value],
|
|
categoryId: value,
|
|
});
|
|
onBulkActionSuccess(
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
|
|
);
|
|
} catch (error) {
|
|
useAlert(
|
|
error?.message ||
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
|
|
);
|
|
}
|
|
};
|
|
|
|
const confirmBulkDelete = () => {
|
|
deleteConfirmDialogRef.value?.open();
|
|
};
|
|
|
|
const bulkDelete = async () => {
|
|
try {
|
|
await articlesAPI.bulkDelete({
|
|
portalSlug: route.params.portalSlug,
|
|
articleIds: [...selectedArticleIds.value],
|
|
});
|
|
deleteConfirmDialogRef.value?.close();
|
|
onBulkActionSuccess(
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
|
);
|
|
} catch (error) {
|
|
deleteConfirmDialogRef.value?.close();
|
|
useAlert(
|
|
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
|
);
|
|
}
|
|
};
|
|
|
|
// Clear selection when articles change (page change, filter change)
|
|
watch(
|
|
() => props.articles,
|
|
() => clearSelection()
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<HelpCenterLayout
|
|
:current-page="Number(meta.currentPage)"
|
|
:total-items="articlesCount"
|
|
:items-per-page="25"
|
|
:header="portalName"
|
|
:show-pagination-footer="shouldShowPaginationFooter"
|
|
@update:current-page="handlePageChange"
|
|
>
|
|
<template #title-actions>
|
|
<Input
|
|
v-if="!isSwitchingPortal"
|
|
v-model="searchQuery"
|
|
:placeholder="
|
|
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
|
|
"
|
|
type="search"
|
|
size="sm"
|
|
class="w-full max-w-[16rem] min-w-0"
|
|
@input="debouncedSearch"
|
|
/>
|
|
</template>
|
|
<template #header-actions>
|
|
<div class="flex items-end justify-between">
|
|
<ArticleHeaderControls
|
|
v-if="showArticleHeaderControls"
|
|
:categories="categories"
|
|
:allowed-locales="allowedLocales"
|
|
:meta="meta"
|
|
@tab-change="handleTabChange"
|
|
@locale-change="handleLocaleAction"
|
|
@category-change="handleCategoryAction"
|
|
@new-article="navigateToNewArticlePage"
|
|
/>
|
|
<CategoryHeaderControls
|
|
v-else-if="showCategoryHeaderControls"
|
|
:categories="categories"
|
|
:allowed-locales="allowedLocales"
|
|
:has-selected-category="isCategoryArticles"
|
|
@new-article="navigateToNewArticlePage"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div
|
|
v-if="isLoading && !isArticleDragging"
|
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
|
>
|
|
<Spinner />
|
|
</div>
|
|
<template v-else-if="!hasNoArticles">
|
|
<div
|
|
v-if="selectedArticleIds.size > 0"
|
|
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
|
>
|
|
<BulkSelectBar
|
|
v-model="selectedArticleIds"
|
|
:all-items="allItems"
|
|
:select-all-label="selectAllLabel"
|
|
:selected-count-label="selectedCountLabel"
|
|
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
|
>
|
|
<template #secondaryActions>
|
|
<Button
|
|
sm
|
|
ghost
|
|
slate
|
|
:label="
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
|
"
|
|
class="!px-1.5"
|
|
@click="clearSelection"
|
|
/>
|
|
</template>
|
|
<template #actions>
|
|
<div class="flex items-center gap-2 ml-auto">
|
|
<Button
|
|
sm
|
|
faded
|
|
slate
|
|
icon="i-lucide-check"
|
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
|
@click="bulkUpdateStatus('published')"
|
|
/>
|
|
<Button
|
|
sm
|
|
faded
|
|
slate
|
|
icon="i-lucide-pencil-line"
|
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
|
@click="bulkUpdateStatus('draft')"
|
|
/>
|
|
<Button
|
|
sm
|
|
faded
|
|
slate
|
|
icon="i-lucide-archive-restore"
|
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
|
@click="bulkUpdateStatus('archived')"
|
|
/>
|
|
<div v-if="categoryMenuItems.length" class="relative group">
|
|
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
|
<Button
|
|
sm
|
|
faded
|
|
slate
|
|
icon="i-lucide-folder-input"
|
|
:label="
|
|
t(
|
|
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
|
|
)
|
|
"
|
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
|
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
|
/>
|
|
<DropdownMenu
|
|
v-if="isCategoryMenuOpen"
|
|
:menu-items="categoryMenuItems"
|
|
show-search
|
|
class="right-0 w-48 mt-2 top-full max-h-60"
|
|
@action="handleBulkUpdateCategory"
|
|
/>
|
|
</OnClickOutside>
|
|
</div>
|
|
<Button
|
|
v-if="isTranslationAvailable"
|
|
sm
|
|
faded
|
|
slate
|
|
icon="i-lucide-languages"
|
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
|
@click="openTranslateDialog"
|
|
/>
|
|
<Button
|
|
sm
|
|
faded
|
|
ruby
|
|
icon="i-lucide-trash"
|
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
|
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
|
@click="confirmBulkDelete"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</BulkSelectBar>
|
|
</div>
|
|
<ArticleList
|
|
:articles="articles"
|
|
:is-category-articles="isCategoryArticles"
|
|
:is-searching="isSearching"
|
|
:selected-article-ids="selectedArticleIds"
|
|
:current-page="Number(meta.currentPage)"
|
|
:total-pages="totalPages"
|
|
class="relative z-0"
|
|
@translate-article="handleTranslateArticle"
|
|
@toggle-select="handleToggleSelect"
|
|
@navigate-page="handlePageChange"
|
|
@dragging="isArticleDragging = $event"
|
|
/>
|
|
</template>
|
|
<ArticleEmptyState
|
|
v-else
|
|
class="pt-14"
|
|
:title="getEmptyStateTitle"
|
|
:subtitle="getEmptyStateSubtitle"
|
|
:show-button="hasNoArticlesInPortal && !isSearching"
|
|
:button-label="
|
|
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
|
"
|
|
@click="navigateToNewArticlePage"
|
|
/>
|
|
</template>
|
|
<BulkTranslateDialog
|
|
ref="bulkTranslateDialogRef"
|
|
:selected-article-ids="[...selectedArticleIds]"
|
|
:allowed-locales="allowedLocales"
|
|
@translate-started="clearSelection"
|
|
/>
|
|
<Dialog
|
|
ref="deleteConfirmDialogRef"
|
|
type="alert"
|
|
:title="
|
|
t(
|
|
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
|
selectedArticleIds.size
|
|
)
|
|
"
|
|
:description="
|
|
t(
|
|
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
|
selectedArticleIds.size
|
|
)
|
|
"
|
|
:confirm-button-label="
|
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
|
"
|
|
@confirm="bulkDelete"
|
|
/>
|
|
</HelpCenterLayout>
|
|
</template>
|