feat: add search to Help Center admin pages (#14772)

This commit is contained in:
Sivin Varghese
2026-06-19 12:41:21 +05:30
committed by GitHub
parent ac15339456
commit 32b63ad0c0
12 changed files with 184 additions and 42 deletions
@@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
@@ -210,9 +210,9 @@ const handleClick = id => {
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
@@ -223,19 +223,17 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
>
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
{{ categoryName }}
<span class="truncate">{{ categoryName }}</span>
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
@@ -247,7 +245,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1">
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ lastUpdatedAt }}
</span>
</div>
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<div v-if="activePortalName" class="relative shrink-0 group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
@@ -91,6 +91,9 @@ const togglePortalSwitcher = () => {
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>
</div>
<slot name="header-actions" />
</div>
@@ -24,6 +24,10 @@ const props = defineProps({
type: Set,
default: () => new Set(),
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
@@ -41,6 +45,7 @@ const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
return (
props.isCategoryArticles &&
!props.isSearching &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { 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 { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
@@ -18,6 +19,7 @@ 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';
@@ -49,7 +51,12 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const emit = defineEmits([
'pageChange',
'fetchPortal',
'refreshArticles',
'search',
]);
const router = useRouter();
const route = useRoute();
@@ -65,6 +72,9 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
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();
@@ -122,6 +132,7 @@ const updateRoute = newParams => {
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
query: route.query,
});
};
@@ -145,7 +156,12 @@ 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}`);
}
@@ -291,6 +307,19 @@ watch(
: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
@@ -422,6 +451,7 @@ watch(
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@@ -433,7 +463,7 @@ watch(
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal"
:show-button="hasNoArticlesInPortal && !isSearching"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@@ -37,10 +37,21 @@ const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const hasCategories = computed(() => props.categories?.length > 0);
const filteredCategories = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.categories;
return props.categories.filter(category =>
category.name?.toLowerCase().includes(query)
);
});
const hasCategories = computed(() => filteredCategories.value?.length > 0);
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const updateRoute = (newParams, routeName) => {
const { accountId, portalSlug, locale } = route.params;
@@ -115,6 +126,7 @@ const reorderCategories = async reorderedGroup => {
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
v-model:search-query="searchQuery"
:categories="categories"
:is-category-articles="false"
:allowed-locales="allowedLocales"
@@ -130,11 +142,18 @@ const reorderCategories = async reorderedGroup => {
</div>
<CategoryList
v-else-if="hasCategories"
:categories="categories"
:categories="filteredCategories"
:disable-drag="isSearching"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else-if="isSearching"
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
/>
<CategoryEmptyState
v-else
class="pt-14"
@@ -6,6 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
@@ -27,6 +28,11 @@ const props = defineProps({
const emit = defineEmits(['localeChange', 'newArticle']);
const searchQuery = defineModel('searchQuery', {
type: String,
default: '',
});
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
@@ -162,23 +168,34 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-48"
/>
<div class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
</div>
<div v-else class="relative flex items-center gap-2">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
@@ -8,6 +8,10 @@ const props = defineProps({
type: Array,
required: true,
},
disableDrag: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'action', 'reorder']);
@@ -15,7 +19,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
return !props.disableDrag && localCategories.value?.length > 1;
});
const handleClick = slug => {
@@ -2,8 +2,12 @@
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useI18n } from 'vue-i18n';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
@@ -19,7 +23,10 @@ const props = defineProps({
},
});
const { t } = useI18n();
const addLocaleDialogRef = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -28,6 +35,19 @@ const openAddLocaleDialog = () => {
};
const localeCount = computed(() => props.locales?.length);
const filteredLocales = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.locales;
return props.locales.filter(
locale =>
locale.name?.toLowerCase().includes(query) ||
locale.code?.toLowerCase().includes(query)
);
});
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasResults = computed(() => filteredLocales.value?.length > 0);
</script>
<template>
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
<div class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
type="search"
size="sm"
class="w-48"
/>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</div>
</template>
<template #content>
@@ -54,7 +83,13 @@ const localeCount = computed(() => props.locales?.length);
>
<Spinner />
</div>
<LocaleList v-else :locales="locales" :portal="portal" />
<EmptyStateLayout
v-else-if="isSearching && !hasResults"
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
:show-backdrop="false"
/>
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
@@ -552,6 +552,7 @@
"LOCALE": {
"ALL": "All locales"
},
"SEARCH_PLACEHOLDER": "Search articles...",
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
@@ -579,6 +580,10 @@
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
},
"SEARCH": {
"TITLE": "No matching articles",
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -624,6 +629,7 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
"SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
@@ -634,6 +640,10 @@
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching categories",
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
@@ -691,6 +701,11 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale",
"SEARCH_PLACEHOLDER": "Search locales...",
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching locales",
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
},
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
@@ -1,15 +1,17 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const pageNumber = ref(1);
const searchQuery = ref(route.query.search || '');
const allArticles = useMapGetter('articles/allArticles');
const articlesSortedByPosition = useMapGetter(
@@ -74,6 +76,7 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
status: status.value,
authorId: author.value,
categorySlug: selectedCategorySlug.value,
query: searchQuery.value || undefined,
});
};
@@ -81,6 +84,15 @@ const onPageChange = pageNumberParam => {
fetchArticles({ pageNumber: pageNumberParam });
};
const onSearch = query => {
searchQuery.value = query;
pageNumber.value = 1;
router.replace({
query: { ...route.query, search: query || undefined },
});
fetchArticles({ pageNumber: 1 });
};
const fetchPortalAndItsCategories = async locale => {
await store.dispatch('portals/index');
const selectedPortalParam = {
@@ -118,6 +130,7 @@ watch(
:portal-meta="portalMeta"
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@search="onSearch"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
@@ -7,7 +7,7 @@ import types from '../../mutation-types';
export const actions = {
index: async (
{ commit },
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
@@ -18,6 +18,7 @@ export const actions = {
status,
authorId,
categorySlug,
query,
});
const payload = camelcaseKeys(data.payload);
const meta = camelcaseKeys(data.meta);