Files
zammad/app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseTopBarHeader.vue

260 lines
10 KiB
Vue

<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { isEqual } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { KnowledgeBaseCategoryPolicyFragment } from '#shared/graphql/types.ts'
import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
import { usePage } from '#desktop/composables/usePage.ts'
import { useKnowledgeBaseAccess } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseCategoryDelete } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseCategoryDelete.ts'
import { useKnowledgeBaseStore } from '#desktop/entities/knowledge-base/stores/knowledgeBase.ts'
import { knowledgeBaseBrowseRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import TopBarHeaderCompact from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/TopBarHeaderCompact.vue'
import TopBarHeaderFull from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/TopBarHeaderFull.vue'
import TopBarHeaderFullSkeleton from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/TopBarHeaderFullSkeleton.vue'
import TopBarHeaderShell from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/TopBarHeaderShell.vue'
import { openKnowledgeBaseCategoryEditFlyout } from '../../composables/useKnowledgeBaseCategoryFlyout.ts'
import { openKnowledgeBaseEditFlyout } from '../../composables/useKnowledgeBaseEditFlyout.ts'
import { useKnowledgeBaseFeedAction } from '../../composables/useKnowledgeBaseFeedAction.ts'
import { knowledgeBasePreviewUrl } from '../../composables/useKnowledgeBasePreviewUrl.ts'
import { armKnowledgeBaseSorting } from '../../composables/useKnowledgeBaseSorting.ts'
import { knowledgeBaseBreadcrumbItems } from '../../utils/knowledgeBaseBreadcrumbItems.ts'
import { knowledgeBaseBrowsedTitle } from '../../utils/knowledgeBaseBrowsedTitle.ts'
import { useKnowledgeBaseHeaderLocales } from './useKnowledgeBaseHeaderLocales.ts'
import type { TopBarHeaderProps } from './types.ts'
import type { CategoryBreadcrumb, KnowledgeBaseSortingModes } from '../../types.ts'
type Props = {
contentContainerElement: HTMLElement | null
categoryBreadcrumb?: CategoryBreadcrumb
// Whether the opened category shows public content in the current locale, so
// the header can offer its public-site link while browsing that category.
categoryVisiblePublicly?: boolean
// Whether the opened category has no own translation in the browsed locale, so
// the header can dock a warning alert below itself (full and compact alike).
categoryTranslationMissing?: boolean
// Whether the opened category may be deleted at all — undefined until its query
// resolves, which the header outlives: opening from a card renders it straight from
// the cached breadcrumb. The delete flow treats that as "ask the server".
categoryDeletable?: boolean
// Per-record permissions of the opened category; undefined until its query resolves,
// which withholds the actions rather than offering ones the mutation would refuse.
categoryPolicy?: KnowledgeBaseCategoryPolicyFragment['policy']
// Whether the browsed node has content worth sorting: the editor access to arrange it, and
// more than one category or more than one answer to arrange. Decided by the page, which is
// the one holding both listings.
canSortContent?: boolean
// The modes the browsed node's lists are stored with, which is where the sorting bar starts.
// Likewise from the page: the root reads its one mode off the knowledge base, a category the
// two of its own. Empty until they resolve, which leaves each list on the default.
sortingModes?: KnowledgeBaseSortingModes
// The page's content-loading state (a fresh, uncached category load), so the
// header can skeleton in lockstep instead of flashing a stale title.
loading?: boolean
}
const props = defineProps<Props>()
const router = useRouter()
const { activeLocale, knowledgeBase, loading: baseLoading } = storeToRefs(useKnowledgeBaseStore())
// Skeleton on the initial base load and again on a fresh (uncached) category
// load, so the header never flashes a stale title before the new breadcrumb
// resolves.
const loading = computed(() => baseLoading.value || Boolean(props.loading))
const title = computed(() =>
knowledgeBaseBrowsedTitle({
categoryBreadcrumb: props.categoryBreadcrumb,
knowledgeBaseTitle: knowledgeBase.value?.translation?.title,
}),
)
const { canEdit, canRead } = useKnowledgeBaseAccess()
// Deep-link the "view public knowledge base" button to what is being browsed:
// the opened category, or the base root. The link is only for internal users
// (reader/editor) — never public visitors — and is offered when that node
// shows public content in the current locale, or the user is an editor (who
// can preview unpublished content); undefined otherwise hides the button.
const previewUrl = computed(() => {
const locale = activeLocale.value
if (!locale || !canRead.value) return undefined
const openedCategory = props.categoryBreadcrumb?.at(-1)
if (openedCategory) {
if (!props.categoryVisiblePublicly && !canEdit.value) return undefined
return knowledgeBasePreviewUrl('KnowledgeBaseCategory', openedCategory.id, locale)
}
const base = knowledgeBase.value
if (!base || (!base.isVisiblePublicly && !canEdit.value)) return undefined
return knowledgeBasePreviewUrl('KnowledgeBase', base.id, locale)
})
const openedCategoryId = computed(() => props.categoryBreadcrumb?.at(-1)?.id)
const { feedActions } = useKnowledgeBaseFeedAction(openedCategoryId)
const metaTitle = computed(() => {
const categoryTitle = props.categoryBreadcrumb?.at(-1)?.translation?.title
const kbTitle = knowledgeBase.value?.translation?.title ?? __('Knowledge Base')
return categoryTitle ? `${kbTitle} - ${categoryTitle}` : kbTitle
})
usePage({
metaTitle,
})
const breadcrumbItems = computed(() =>
knowledgeBaseBreadcrumbItems({
localeCode: activeLocale.value,
categoryBreadcrumb: props.categoryBreadcrumb,
}),
)
const { localeItems, selectedLocaleItem, selectedLocaleCode } = useKnowledgeBaseHeaderLocales(
// Stay in the open category when there is one, otherwise land on the
// localized root.
(localeCode) =>
router.push(knowledgeBaseBrowseRoute(localeCode, props.categoryBreadcrumb?.at(-1)?.id)),
)
// The header acts on the node currently open: the knowledge base root, or the opened category.
// Adding is not offered here at all: it belongs to the browse grid's add card (and the floating
// toolbar once that card is scrolled past).
//
// Kept out of `headerProps` below: that computed caches on deep equality, which menu
// items — carrying callbacks — would defeat.
//
// Gated by the opened category's own policy rather than by the global editor permission:
// granular permissions can narrow an editor to a part of the tree (the legacy stack gates
// per record as well).
const { confirmCategoryDelete } = useKnowledgeBaseCategoryDelete()
// The same entry on both levels - the root arranges its top-level categories, a category its
// own children and answers.
const sortContentAction: MenuItem = {
key: 'sort-content',
label: __('Sort content'),
icon: 'grid',
onClick: () => armKnowledgeBaseSorting(props.sortingModes),
}
const actions = computed<MenuItem[]>(() => {
const breadcrumbCategory = props.categoryBreadcrumb?.at(-1)
// Flattened for the two dialogs, which prefill a form rather than read the graph.
const openedCategory = breadcrumbCategory && {
id: breadcrumbCategory.id,
title: breadcrumbCategory.translation?.title ?? '',
categoryIcon: breadcrumbCategory.categoryIcon,
}
const items: MenuItem[] = [...feedActions.value]
if (!openedCategory) {
if (knowledgeBase.value?.policy.update) {
items.unshift({
key: 'edit-knowledge-base',
label: __('Edit knowledge base'),
icon: 'pencil',
onClick: () => openKnowledgeBaseEditFlyout(),
})
}
if (props.canSortContent) items.push(sortContentAction)
return items
}
if (props.categoryPolicy?.update) {
items.unshift({
key: 'edit-category',
label: __('Edit category'),
icon: 'pencil',
onClick: () => openKnowledgeBaseCategoryEditFlyout(openedCategory),
})
}
if (props.canSortContent) items.push(sortContentAction)
if (props.categoryPolicy?.destroy) {
items.push({
key: 'delete-category',
label: __('Delete category'),
icon: 'trash3',
separatorTop: true,
// Deleting the page the user is standing on: hand over the breadcrumb parent as
// the navigation target — the localised root when the category is top level.
onClick: () =>
confirmCategoryDelete(
{ ...openedCategory, isDeletable: props.categoryDeletable },
{ parentCategoryId: props.categoryBreadcrumb?.at(-2)?.id },
),
})
}
return items
})
const headerProps = computed<TopBarHeaderProps>((currentProps) => {
const updatedProps = {
title: title.value,
locales: localeItems.value,
breadcrumbs: breadcrumbItems.value,
localeCode: selectedLocaleCode.value,
previewUrl: previewUrl.value,
actions: actions.value,
}
if (currentProps && isEqual(currentProps, updatedProps)) return currentProps
return updatedProps
})
</script>
<template>
<TopBarHeaderShell
:content-container-element="contentContainerElement"
:loading="loading"
:alert-message="
categoryTranslationMissing ? $t('No translation available for this locale') : undefined
"
>
<template #compact="{ inert }">
<TopBarHeaderCompact
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:copy-label="__('Copy category title')"
:inert="inert"
/>
</template>
<template #full="{ inert }">
<TopBarHeaderFull
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:copy-label="__('Copy category title')"
:inert="inert"
/>
</template>
<template #skeleton>
<TopBarHeaderFullSkeleton />
</template>
</TopBarHeaderShell>
</template>