mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat(v4): Help center portal redesign improvements (#10349)
This commit is contained in:
@@ -30,7 +30,7 @@ const props = defineProps({
|
||||
},
|
||||
author: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: Object,
|
||||
@@ -157,7 +157,6 @@ const handleClick = id => {
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Thumbnail
|
||||
v-if="author"
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
@@ -19,6 +20,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const openAgentsList = ref(false);
|
||||
const openCategoryList = ref(false);
|
||||
@@ -36,13 +38,15 @@ const currentUser = computed(() =>
|
||||
agents.value.find(agent => agent.id === currentUserId.value)
|
||||
);
|
||||
|
||||
const categorySlugFromRoute = computed(() => route.params.categorySlug);
|
||||
|
||||
const author = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
return selectedAuthorId.value
|
||||
? agents.value.find(agent => agent.id === selectedAuthorId.value)
|
||||
: currentUser.value;
|
||||
}
|
||||
return props.article?.author || currentUser.value;
|
||||
return props.article?.author || null;
|
||||
});
|
||||
|
||||
const authorName = computed(
|
||||
@@ -51,24 +55,52 @@ const authorName = computed(
|
||||
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
|
||||
|
||||
const agentList = computed(() => {
|
||||
return [...agents.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(agent => ({
|
||||
label: agent.name,
|
||||
value: agent.id,
|
||||
thumbnail: { name: agent.name, src: agent.thumbnail },
|
||||
isSelected: agent.id === props.article?.author?.id,
|
||||
action: 'assignAuthor',
|
||||
}))
|
||||
.sort((a, b) => b.isSelected - a.isSelected);
|
||||
return (
|
||||
agents.value
|
||||
?.map(({ name, id, thumbnail }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
thumbnail: { name, src: thumbnail },
|
||||
isSelected:
|
||||
id === props.article?.author?.id ||
|
||||
id === (selectedAuthorId.value || currentUserId.value),
|
||||
action: 'assignAuthor',
|
||||
}))
|
||||
// Sort the list by isSelected first, then by name(label)
|
||||
.toSorted((a, b) => {
|
||||
if (a.isSelected !== b.isSelected) {
|
||||
return Number(b.isSelected) - Number(a.isSelected);
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
const hasAgentList = computed(() => {
|
||||
return agents.value?.length > 0;
|
||||
return agents.value?.length > 1;
|
||||
});
|
||||
|
||||
const findCategoryFromSlug = slug => {
|
||||
return categories.value?.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
const assignCategoryFromSlug = slug => {
|
||||
const categoryFromSlug = findCategoryFromSlug(slug);
|
||||
if (categoryFromSlug) {
|
||||
selectedCategoryId.value = categoryFromSlug.id;
|
||||
return categoryFromSlug;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
if (categorySlugFromRoute.value) {
|
||||
const categoryFromSlug = assignCategoryFromSlug(
|
||||
categorySlugFromRoute.value
|
||||
);
|
||||
if (categoryFromSlug) return categoryFromSlug;
|
||||
}
|
||||
return selectedCategoryId.value
|
||||
? categories.value.find(
|
||||
category => category.id === selectedCategoryId.value
|
||||
@@ -81,15 +113,20 @@ const selectedCategory = computed(() => {
|
||||
});
|
||||
|
||||
const categoryList = computed(() => {
|
||||
return categories.value
|
||||
.map(category => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
emoji: category.icon,
|
||||
isSelected: category.id === props.article?.category?.id,
|
||||
action: 'assignCategory',
|
||||
}))
|
||||
.sort((a, b) => b.isSelected - a.isSelected);
|
||||
return (
|
||||
categories.value
|
||||
.map(({ name, id, icon }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
emoji: icon,
|
||||
isSelected: isNewArticle.value
|
||||
? id === (selectedCategoryId.value || selectedCategory.value?.id)
|
||||
: id === props.article?.category?.id,
|
||||
action: 'assignCategory',
|
||||
}))
|
||||
// Sort categories by isSelected
|
||||
.toSorted((a, b) => Number(b.isSelected) - Number(a.isSelected))
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategoryMenuItems = computed(() => {
|
||||
@@ -124,6 +161,19 @@ const handleArticleAction = ({ action, value }) => {
|
||||
const updateMeta = meta => {
|
||||
emit('saveArticle', { meta });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (categorySlugFromRoute.value && isNewArticle.value) {
|
||||
// Assign category from slug if there is one
|
||||
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
|
||||
if (categoryFromSlug) {
|
||||
handleArticleAction({
|
||||
action: 'assignCategory',
|
||||
value: categoryFromSlug?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,7 +189,6 @@ const updateMeta = meta => {
|
||||
>
|
||||
<template #leftPrefix>
|
||||
<Thumbnail
|
||||
v-if="author"
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:size="20"
|
||||
|
||||
@@ -114,15 +114,23 @@ 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 = () =>
|
||||
router.push({ name: 'portals_articles_new' });
|
||||
|
||||
const navigateToNewArticlePage = () => {
|
||||
const { categorySlug, locale } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_new',
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.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';
|
||||
|
||||
@@ -18,6 +21,8 @@ const props = defineProps({
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const openAddLocaleDialog = () => {
|
||||
addLocaleDialogRef.value.dialogRef.open();
|
||||
};
|
||||
@@ -43,7 +48,13 @@ const localeCount = computed(() => props.locales?.length);
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<LocaleList :locales="locales" :portal="portal" />
|
||||
<div
|
||||
v-if="isSwitchingPortal"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
@@ -25,6 +26,10 @@ const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const portalLink = computed(() => {
|
||||
return buildPortalURL(currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const isPortalActive = portal => {
|
||||
return portal.slug === currentPortalSlug.value;
|
||||
};
|
||||
@@ -71,6 +76,10 @@ const openCreatePortalDialog = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onClickPreviewPortal = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
};
|
||||
|
||||
const redirectToPortalHomePage = () => {
|
||||
router.push({
|
||||
name: 'portals_index',
|
||||
@@ -89,12 +98,22 @@ const redirectToPortalHomePage = () => {
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2
|
||||
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
|
||||
@click="redirectToPortalHomePage"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2
|
||||
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
|
||||
@click="redirectToPortalHomePage"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
|
||||
</h2>
|
||||
<Button
|
||||
icon="arrow-up-right-lucide"
|
||||
variant="ghost"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
|
||||
@click="onClickPreviewPortal"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
@@ -30,6 +31,9 @@ const props = defineProps({
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasImageLoaded = ref(false);
|
||||
const imgError = ref(false);
|
||||
|
||||
@@ -108,6 +112,7 @@ const onImgLoad = () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
class="flex items-center justify-center w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700/50"
|
||||
>
|
||||
<FluentIcon
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"CONFIRM": "Confirm"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Author is not available"
|
||||
}
|
||||
},
|
||||
"BREADCRUMB": {
|
||||
"ARIA_LABEL": "Breadcrumb"
|
||||
}
|
||||
|
||||
@@ -532,20 +532,20 @@
|
||||
"BUTTON_LABEL": "New article"
|
||||
},
|
||||
"MINE": {
|
||||
"TITLE": "There are no articles in mine",
|
||||
"SUBTITLE": "Mine articles will appear here"
|
||||
"TITLE": "You haven't written any articles here",
|
||||
"SUBTITLE": "All articles written by you show up here for quick access."
|
||||
},
|
||||
"DRAFT": {
|
||||
"TITLE": "There are no articles in draft",
|
||||
"TITLE": "There are no articles in drafts",
|
||||
"SUBTITLE": "Draft articles will appear here"
|
||||
},
|
||||
"PUBLISHED": {
|
||||
"TITLE": "There are no articles in published",
|
||||
"TITLE": "There are no published articles",
|
||||
"SUBTITLE": "Published articles will appear here"
|
||||
},
|
||||
"ARCHIVED": {
|
||||
"TITLE": "There are no articles in archived",
|
||||
"SUBTITLE": "Archived articles will appear here"
|
||||
"TITLE": "There are no articles in the archive",
|
||||
"SUBTITLE": "Archived articles don't show up on the portal, you can use it to mark deprecated or outdated pages"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"TITLE": "There are no articles in this category",
|
||||
|
||||
@@ -31,7 +31,7 @@ const portalRoutes = [
|
||||
component: PortalsArticlesIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/articles/new'),
|
||||
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/new'),
|
||||
name: 'portals_articles_new',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
|
||||
@@ -300,5 +300,6 @@
|
||||
"m2 22l1-1h3l9-9M3 21v-3l9-9",
|
||||
"m15 6l3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3z"
|
||||
],
|
||||
"building-lucide-outline": "M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Zm0-10H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2M10 6h4m-4 4h4m-4 4h4m-4 4h4"
|
||||
"building-lucide-outline": "M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Zm0-10H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2M10 6h4m-4 4h4m-4 4h4m-4 4h4",
|
||||
"arrow-up-right-lucide-outline": "M7 7h10v10M7 17L17 7"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user