Feature: Desktop view - View an answer in the knowledge base

Approved-by: Marcel Bialas <mb@zammad.com>
Approved-by: Benjamin Scharf <bs@zammad.com>
Approved-by: Dusan Vuckovic <dv@zammad.com>

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Marcel Bialas <mb@zammad.com>
Co-authored-by: Rene Reimann <rr@zammad.com>
This commit is contained in:
Benjamin Scharf
2026-08-17 08:01:05 +02:00
parent 56327c4837
commit 3a08058c1d
219 changed files with 8132 additions and 1161 deletions

View File

@@ -31,7 +31,9 @@ if [[ ${#RUBY_FILES[@]} -gt 0 ]]; then
fi
if [[ ${#FRONTEND_ALL_FILES[@]} -gt 0 ]]; then
{ pnpm lint:js:oxlint:cmd --fix "${FRONTEND_ALL_FILES[@]}" && \
# Generated files (e.g. the GraphQL types) are excluded by the lint configs, so
# a changeset consisting only of those must not be treated as an error.
{ pnpm lint:js:oxlint:cmd --no-error-on-unmatched-pattern --fix "${FRONTEND_ALL_FILES[@]}" && \
pnpm lint:js:eslint:cmd --fix "${FRONTEND_ALL_FILES[@]}" && \
{ pnpm format:cmd "${FRONTEND_ALL_FILES[@]}"; _fmt_rc=$?; (( _fmt_rc == 0 || _fmt_rc == 2 )); }; } >&2 || EXIT_CODE=2
fi

View File

@@ -39,6 +39,7 @@ const scalars = {
ISO8601Date: 'string',
ISO8601DateTime: 'string',
JSON: 'any',
KnowledgeBaseIconSet: "'anticon' | 'FontAwesome' | 'material' | 'ionicons' | 'Simple-Line-Icons'",
UriHttpString: 'string',
}

View File

@@ -4,7 +4,6 @@
import { onBeforeMount, watch } from 'vue'
import { useRouter } from 'vue-router'
import CommonImageViewer from '#shared/components/CommonImageViewer/CommonImageViewer.vue'
import CommonNotifications from '#shared/components/CommonNotifications/CommonNotifications.vue'
import DynamicInitializer from '#shared/components/DynamicInitializer/DynamicInitializer.vue'
import useAuthenticationChanges from '#shared/composables/authentication/useAuthenticationUpdates.ts'
@@ -25,6 +24,7 @@ import {
initializeBetaUiFeedbackConsentDialog,
} from '#desktop/components/BetaUi/composables/useBetaUiFeedbackConsent.ts'
import { initializeConfirmationDialog } from '#desktop/components/CommonConfirmationDialog/initializeConfirmationDialog.ts'
import CommonImageViewer from '#desktop/components/CommonImageViewer/CommonImageViewer.vue'
import { useConnection } from '#desktop/composables/useConnection.ts'
import { useTicketOverviewsStore } from '#desktop/entities/ticket/stores/ticketOverviews.ts'
import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts'

View File

@@ -1,17 +0,0 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { initializeBetaUi } from '#desktop/components/BetaUi/composables/useBetaUi.ts'
// Links which point into the legacy app (e.g. the knowledge base answer view) cannot be
// followed while the BETA UI switch is active: the legacy app redirects straight back
// to the new app on boot, so the user never reaches the target page. Clearing the
// switch flag beforehand keeps the browser navigation itself untouched, which also
// covers links opening in a new tab.
// :TODO Remove once the target views are available in the new app.
export const prepareLegacyAppLinkNavigation = () => {
const { switchValue } = initializeBetaUi()
if (!switchValue.value) return
switchValue.value = null
}

View File

@@ -3,6 +3,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { i18n } from '#shared/i18n.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import type { BreadcrumbItem } from './types.ts'
@@ -32,13 +33,20 @@ const sizeClasses = computed(() => {
return ['text-base'] // default -> 'large'
})
const getItemLabel = (item: BreadcrumbItem) =>
item.noOptionLabelTranslation ? (item.label as string) : i18n.t(item.label as string)
const displayItems = computed(() =>
props.items.map((item) => Object.assign({}, item, { displayLabel: getItemLabel(item) })),
)
</script>
<template>
<nav :class="sizeClasses" :aria-label="$t(label)" class="max-w-full">
<ol class="flex">
<li
v-for="(item, idx) in items"
v-for="(item, idx) in displayItems"
:key="item.label as string"
class="flex items-center"
:class="[lastItemClasses, { 'print:hidden': idx === 0 }]"
@@ -53,7 +61,7 @@ const sizeClasses = computed(() => {
<CommonLink
v-if="item.route && item.iconOnly"
v-tooltip="item.noOptionLabelTranslation ? item.label : $t(item.label as string)"
v-tooltip="item.displayLabel"
class="inline-flex items-center focus-visible-app-default"
:link="item.route"
internal
@@ -69,6 +77,7 @@ const sizeClasses = computed(() => {
<CommonLink
v-else-if="item.route"
v-tooltip.supportive="item.displayLabel"
class="inline-flex items-center gap-1 focus-visible-app-default"
:link="item.route"
internal
@@ -82,21 +91,22 @@ const sizeClasses = computed(() => {
/>
<CommonLabel class="line-clamp-1! hover:text-black hover:dark:text-white" :size="size">
{{ item.noOptionLabelTranslation ? item.label : $t(item.label as string) }}
{{ item.displayLabel }}
</CommonLabel>
</CommonLink>
<component
:is="items.at(-1) === item ? 'h1' : 'span'"
:is="displayItems.at(-1) === item ? 'h1' : 'span'"
v-else
v-tooltip.supportive="item.displayLabel"
class="line-clamp-1"
:class="{
'text-black dark:text-white': item.isActive,
'break-all': items.at(-1) === item,
'break-all': displayItems.at(-1) === item,
}"
aria-current="page"
>
{{ item.noOptionLabelTranslation ? item.label : $t(item.label as string) }}
{{ item.displayLabel }}
</component>
<CommonBadge
@@ -109,14 +119,14 @@ const sizeClasses = computed(() => {
</CommonBadge>
<CommonIcon
v-if="idx !== items.length - 1"
v-if="idx !== displayItems.length - 1"
:name="locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'"
size="xs"
class="mx-1 inline-flex shrink-0 text-stone-200 dark:text-neutral-500"
/>
<!-- Add a slot at the end of the last item. -->
<slot v-if="idx === items.length - 1" name="trailing" />
<slot v-if="idx === displayItems.length - 1" name="trailing" />
</li>
</ol>
</nav>

View File

@@ -0,0 +1,38 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts" generic="T extends FileListFile">
import type { FilePreview } from '#shared/utils/files.ts'
import CommonFileListItem from './CommonFileListItem.vue'
import type { FileListFile } from './types.ts'
defineProps<{
files: T[]
label?: string
noRemove?: boolean
}>()
defineEmits<{
preview: [type: FilePreview, file: T]
remove: [file: T]
}>()
</script>
<template>
<div class="@container/file-list rounded-lg bg-blue-200 p-1 dark:bg-gray-700">
<ul
:aria-label="label"
class="grid grid-cols-1 gap-2 @md/file-list:grid-cols-2 @2xl/file-list:grid-cols-3"
>
<li v-for="file of files" :key="file.internalId">
<CommonFileListItem
:file="file"
:no-remove="noRemove"
@preview="$emit('preview', $event, file)"
@remove="$emit('remove', file)"
/>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,213 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { isEqual } from 'lodash-es'
import { computed, ref } from 'vue'
import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
import { i18n } from '#shared/i18n.ts'
import {
canDownloadFile,
canPreviewFile,
humanizeFileSize,
splitFileName,
type FilePreview,
} from '#shared/utils/files.ts'
import { getIconByContentType } from '#shared/utils/icons.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import type { FileListFile } from './types.ts'
interface Props {
file: FileListFile
noRemove?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
preview: [type: FilePreview]
remove: []
}>()
const { isTouchDevice } = useTouchDevice()
const imageFailed = ref(false)
// A file is only previewable when the server also produced a preview for it — and an
// image whose thumbnail failed to load falls back to its content-type icon.
const canPreview = computed(() => {
if (!props.file.preview || imageFailed.value) return false
return canPreviewFile(props.file.type)
})
const canDownload = computed(() => canDownloadFile(props.file.type))
const icon = computed(() => getIconByContentType(props.file.type))
const fileNameParts = computed(() => splitFileName(props.file.name))
const fileSizeLabel = computed(() => {
if (props.file.size === null || props.file.size === undefined) return undefined
return humanizeFileSize(props.file.size)
})
// Previewable files open the preview, everything else downloads right away — so the
// whole row always does the one thing that makes sense for that file, as a button or
// as a link.
const buildPrimaryAction = () => {
if (canPreview.value) {
return {
component: 'button',
label: i18n.t('Preview %s', props.file.name),
attrs: { type: 'button' },
}
}
if (canDownload.value) {
return {
component: 'CommonLink',
label: i18n.t('Download %s', props.file.name),
attrs: { link: props.file.downloadUrl, download: props.file.name },
}
}
// Cannot be sent as a download (`text/html`), so it opens in a new tab instead.
return {
component: 'CommonLink',
label: i18n.t('Open %s', props.file.name),
attrs: { link: props.file.downloadUrl, target: '_blank' },
}
}
// On a row that is itself the download link this icon is a visual duplicate of it, so
// it stays on screen but is taken out of the accessibility tree and the tab order
// instead of announcing the same destination twice.
const buildDownloadAction = () => {
const redundant = !canPreview.value
return {
label: canDownload.value
? i18n.t('Download file: %s', props.file.name)
: i18n.t('Open file: %s', props.file.name),
attrs: {
link: props.file.downloadUrl,
download: canDownload.value ? props.file.name : undefined,
target: canDownload.value ? undefined : '_blank',
'aria-hidden': redundant ? 'true' : undefined,
tabindex: redundant ? -1 : 0,
},
}
}
// A rebuilt object is a new reference even when it describes the very same action, so
// the prev-compare hands back the previous one and `v-bind` has nothing to patch
// unless the file itself changed.
const primaryAction = computed<ReturnType<typeof buildPrimaryAction>>((currentValue) => {
const action = buildPrimaryAction()
return currentValue && isEqual(currentValue, action) ? currentValue : action
})
const downloadAction = computed<ReturnType<typeof buildDownloadAction>>((currentValue) => {
const action = buildDownloadAction()
return currentValue && isEqual(currentValue, action) ? currentValue : action
})
const onPrimaryClick = () => {
if (!canPreview.value) return
emit('preview', canPreview.value)
}
</script>
<template>
<div
data-test-id="file-list-item"
class="group flex items-center gap-2 rounded-[calc(var(--radius-lg)-var(--spacing))] p-2.5"
:class="{
// Only a file that can be previewed rests on its own surface — the neutral
// background is what marks the row as something to open.
'border border-neutral-100 bg-neutral-50 transition-colors hover:bg-blue-600 active:bg-blue-800! active:text-white dark:border-gray-900 dark:bg-gray-500 dark:hover:bg-blue-900':
canPreview,
'hover:outline hover:outline-blue-800': !canPreview,
}"
>
<component
:is="primaryAction.component"
v-tooltip="primaryAction.label"
v-bind="primaryAction.attrs"
class="flex min-w-0 flex-1 items-center gap-2 text-left no-underline! focus-visible-app-default select-none focus-visible:rounded-[3px]"
@click="onPrimaryClick"
>
<img
v-if="canPreview === 'image'"
class="size-9 shrink-0 rounded-[3px] border border-neutral-100 object-cover dark:border-gray-900"
:src="file.preview"
:alt="$t('Image name: %s', file.name)"
@error="imageFailed = true"
/>
<div
v-else
class="flex size-9 shrink-0 items-center justify-center rounded-[3px] text-stone-200 dark:text-neutral-500"
:class="{
'border border-neutral-100 group-active:text-white dark:border-gray-900': canPreview,
}"
>
<CommonIcon size="base" decorative :name="icon" />
</div>
<div
class="flex min-w-0 flex-1 flex-col text-sm leading-snug text-black dark:text-white"
:class="{ 'group-active:text-white': canPreview }"
>
<div class="flex">
<span class="line-clamp-1 min-w-0 break-all">{{ fileNameParts.base }}</span>
<span v-if="fileNameParts.ext" class="shrink-0">{{ fileNameParts.ext }}</span>
</div>
<CommonLabel
v-if="fileSizeLabel"
size="small"
class="line-clamp-1 leading-snug text-stone-200 dark:text-neutral-500"
:class="{ 'group-active:text-white': canPreview }"
>
{{ fileSizeLabel }}
</CommonLabel>
</div>
</component>
<div
data-test-id="file-list-item-actions"
class="flex shrink-0 items-center gap-1 group-hover:opacity-100 focus-within:opacity-100"
:class="{ 'opacity-0 transition-opacity': !isTouchDevice }"
>
<CommonLink
v-tooltip="downloadAction.label"
v-bind="downloadAction.attrs"
class="rounded-xs p-0.5 text-stone-200! focus-visible-app-default hover:text-black! dark:text-neutral-500! dark:hover:text-white!"
:class="{
'group-hover:text-black! dark:group-hover:text-white!': !canPreview,
'group-active:text-white!': canPreview,
}"
@mousedown.stop
>
<CommonIcon size="xs" decorative name="download" />
</CommonLink>
<CommonButton
v-if="!noRemove"
v-tooltip="$t('Remove file: %s', file.name)"
icon="x-lg"
size="small"
variant="remove"
@click.stop="$emit('remove')"
@mousedown.stop
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,98 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import CommonFileList from '../CommonFileList.vue'
import type { FileListFile } from '../types.ts'
const files: FileListFile[] = [
{
internalId: 1,
name: 'IMG_1234.png',
type: 'image/png',
size: 248_000,
preview: '/api/attachments/1?preview=1',
downloadUrl: '/api/attachments/1?disposition=attachment',
},
{
internalId: 2,
name: 'SomeMeetingFile.ics',
type: 'text/calendar',
size: 248_000,
preview: '/api/attachments/2?preview=1',
downloadUrl: '/api/attachments/2?disposition=attachment',
},
{
internalId: 3,
name: 'SomeTableFile.csv',
type: 'text/csv',
size: 248_000,
preview: '',
downloadUrl: '/api/attachments/3?disposition=attachment',
},
]
const renderFileList = (props: { files: FileListFile[]; label?: string; noRemove?: boolean }) =>
renderComponent(CommonFileList, { props, router: true, store: true })
describe('CommonFileList', () => {
beforeEach(() => {
mockApplicationConfig({
api_path: '/api',
'active_storage.content_types_allowed_inline': ['image/png', 'image/jpeg'],
})
})
it('renders one list item per file', () => {
const view = renderFileList({ files, label: 'Attachments' })
expect(view.getByRole('list', { name: 'Attachments' })).toBeInTheDocument()
expect(view.getAllByRole('listitem')).toHaveLength(3)
expect(view.getByText('IMG_1234')).toBeInTheDocument()
expect(view.getByText('SomeMeetingFile')).toBeInTheDocument()
expect(view.getByText('SomeTableFile')).toBeInTheDocument()
})
it('adapts from three to one column with its container', () => {
const view = renderFileList({ files })
expect(view.getByRole('list')).toHaveClass(
'grid-cols-1',
'@md/file-list:grid-cols-2',
'@2xl/file-list:grid-cols-3',
)
})
it('renders nothing but the list for an empty file set', () => {
const view = renderFileList({ files: [] })
expect(view.queryAllByRole('listitem')).toHaveLength(0)
})
it('bubbles preview with the type and the original file object', async () => {
const view = renderFileList({ files })
await view.events.click(view.getByRole('button', { name: 'Preview IMG_1234.png' }))
// The very same object goes back out, so `useFilePreviewViewer` can resolve the
// gallery position by identity.
expect(view.emitted().preview).toEqual([['image', files[0]]])
})
it('bubbles remove with the original file object', async () => {
const view = renderFileList({ files })
await view.events.click(view.getByRole('button', { name: 'Remove file: IMG_1234.png' }))
expect(view.emitted().remove).toEqual([[files[0]]])
})
it('hides every remove button with no-remove', () => {
const view = renderFileList({ files, noRemove: true })
expect(view.queryByRole('button', { name: /^Remove/ })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,186 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { fireEvent } from '@testing-library/vue'
import { getByIconName, queryByIconName } from '#tests/support/components/iconQueries.ts'
import { renderComponent } from '#tests/support/components/index.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import CommonFileListItem from '../CommonFileListItem.vue'
import type { FileListFile } from '../types.ts'
const touch = vi.hoisted(() => ({ isTouchDevice: false }))
// A real ref, so the template unwraps it — a plain `{ value }` stub would always read
// as truthy there.
vi.mock('#shared/composables/useTouchDevice.ts', async () => {
const { computed } = await import('vue')
return {
useTouchDevice: () => ({ isTouchDevice: computed(() => touch.isTouchDevice) }),
}
})
const image: FileListFile = {
internalId: 1,
name: 'IMG_1234.png',
type: 'image/png',
size: 248_000,
preview: '/api/attachments/1?preview=1',
downloadUrl: '/api/attachments/1?disposition=attachment',
}
const calendar: FileListFile = {
internalId: 2,
name: 'SomeMeetingFile.ics',
type: 'text/calendar',
size: 248_000,
preview: '/api/attachments/2?preview=1',
downloadUrl: '/api/attachments/2?disposition=attachment',
}
const table: FileListFile = {
internalId: 3,
name: 'SomeTableFile.csv',
type: 'text/csv',
size: 248_000,
preview: '',
downloadUrl: '/api/attachments/3?disposition=attachment',
}
const renderItem = (file: FileListFile, props: { noRemove?: boolean } = {}) =>
renderComponent(CommonFileListItem, {
props: { file, ...props },
router: true,
store: true,
})
describe('CommonFileListItem', () => {
beforeEach(() => {
touch.isTouchDevice = false
mockApplicationConfig({
api_path: '/api',
'active_storage.content_types_allowed_inline': ['image/png', 'image/jpeg'],
})
})
describe('previewable files', () => {
it('renders an image with its thumbnail on the neutral surface', () => {
const view = renderItem(image)
const thumbnail = view.getByAltText('Image name: IMG_1234.png')
expect(thumbnail).toHaveAttribute('src', '/api/attachments/1?preview=1')
expect(view.getByText('IMG_1234')).toBeInTheDocument()
expect(view.getByText('.png')).toBeInTheDocument()
expect(view.getByText('242 KB')).toBeInTheDocument()
expect(view.getByTestId('file-list-item')).toHaveClass('bg-neutral-50', 'dark:bg-gray-500')
})
it('emits preview with the image type', async () => {
const view = renderItem(image)
await view.events.click(view.getByRole('button', { name: 'Preview IMG_1234.png' }))
expect(view.emitted().preview).toEqual([['image']])
})
it('emits preview with the calendar type', async () => {
const view = renderItem(calendar)
const button = view.getByRole('button', { name: 'Preview SomeMeetingFile.ics' })
// No thumbnail for a calendar file — it shows its content-type icon instead
// (`calendar` is aliased to `file-calendar` on desktop).
expect(view.queryByAltText('Image of SomeMeetingFile.ics')).not.toBeInTheDocument()
expect(getByIconName(button, 'file-calendar')).toBeInTheDocument()
await view.events.click(button)
expect(view.emitted().preview).toEqual([['calendar']])
})
it('falls back to the content-type icon and drops the surface when the thumbnail fails', async () => {
const view = renderItem(image)
await fireEvent.error(view.getByAltText('Image name: IMG_1234.png'))
expect(view.queryByAltText('Image name: IMG_1234.png')).not.toBeInTheDocument()
expect(getByIconName(view.getByTestId('file-list-item'), 'file-image')).toBeInTheDocument()
expect(view.getByTestId('file-list-item')).not.toHaveClass('bg-neutral-50')
})
})
describe('files without a preview', () => {
it('renders the size of a zero-byte file', () => {
const view = renderItem({ ...table, size: 0 })
expect(view.getByText('0 Bytes')).toBeInTheDocument()
})
it('renders the row as a download link without the neutral surface', () => {
const view = renderItem(table)
expect(view.getByTestId('file-list-item')).not.toHaveClass('bg-neutral-50')
expect(view.queryByRole('button', { name: /^Preview/ })).not.toBeInTheDocument()
// Only one link is announced: the redundant download icon is aria-hidden here.
const row = view.getByRole('link', { name: 'Download SomeTableFile.csv' })
expect(row).toHaveAttribute('href', '/api/attachments/3?disposition=attachment')
expect(row).toHaveAttribute('download', 'SomeTableFile.csv')
})
it('opens a file that cannot be downloaded in a new tab', () => {
const view = renderItem({ ...table, name: 'page.html', type: 'text/html' })
const row = view.getByRole('link', { name: 'Open page.html' })
expect(row).toHaveAttribute('target', '_blank')
expect(row).not.toHaveAttribute('download')
})
})
describe('actions', () => {
it('offers a download link next to the file', () => {
const view = renderItem(image)
const download = view.getByRole('link', { name: 'Download file: IMG_1234.png' })
expect(download).toHaveAttribute('href', '/api/attachments/1?disposition=attachment')
expect(download).toHaveAttribute('download', 'IMG_1234.png')
expect(getByIconName(download, 'download')).toBeInTheDocument()
})
it('emits remove when the remove button is used', async () => {
const view = renderItem(image)
await view.events.click(view.getByRole('button', { name: 'Remove file: IMG_1234.png' }))
expect(view.emitted().remove).toBeTruthy()
})
it('hides the remove button with no-remove', () => {
const view = renderItem(image, { noRemove: true })
expect(
view.queryByRole('button', { name: 'Remove file: IMG_1234.png' }),
).not.toBeInTheDocument()
expect(queryByIconName(view.getByTestId('file-list-item'), 'x-lg')).not.toBeInTheDocument()
})
it('reveals the actions on hover only on a pointer device', () => {
const view = renderItem(image)
expect(view.getByTestId('file-list-item-actions')).toHaveClass('opacity-0')
})
it('keeps the actions visible on a touch device', () => {
touch.isTouchDevice = true
const view = renderItem(image)
expect(view.getByTestId('file-list-item-actions')).not.toHaveClass('opacity-0')
})
})
})

View File

@@ -0,0 +1,17 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
/**
* The minimum a file needs for the list to render it. `AttachmentWithUrls` from
* `useAttachments()` satisfies this, and consumers keep their own richer type through
* the generic parameter of `CommonFileList`, so emitted files can be handed straight
* to `useFilePreviewViewer()` — which matches files by object identity.
*/
export interface FileListFile {
internalId: number
name: string
size?: Maybe<number>
type?: Maybe<string>
/** Thumbnail URL (`?preview=1`); empty when the file has no server-side preview. */
preview: string
downloadUrl: string
}

View File

@@ -1,14 +1,16 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { Comment, computed, useSlots } from 'vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import { useTransitionConfig } from '#desktop/composables/useTransitionConfig.ts'
export interface Props {
isReachingBottom: boolean
label?: string
orientation?: 'horizontal' | 'vertical'
size?: 'normal' | 'large'
isReachingBottom?: boolean
isReachingTop?: boolean
hidePrimaryAction?: boolean
unreadCount?: number
@@ -16,6 +18,7 @@ export interface Props {
}
const props = withDefaults(defineProps<Props>(), {
orientation: 'vertical',
unreadCount: 0,
unreadTooltip: __('Scroll to unread item'),
label: __('Scroll actions'),
@@ -33,8 +36,18 @@ const countDisplay = computed(() => (props.unreadCount > 9 ? '9+' : props.unread
const showUnreadCount = computed(() => props.unreadCount > 0)
// Generic actions replace the scroll controls, they are always visible.
// Comment-only slot content does not count, some consumers pass placeholder comments.
const hasGenericActions = computed(() =>
Boolean(slots.default?.().some((vnode) => vnode.type !== Comment)),
)
const showElement = computed(
() => !props.isReachingBottom || !props.isReachingTop || showUnreadCount.value,
() =>
hasGenericActions.value ||
!props.isReachingBottom ||
!props.isReachingTop ||
showUnreadCount.value,
)
const { transitions } = useTransitionConfig()
@@ -44,17 +57,20 @@ const { transitions } = useTransitionConfig()
<div
v-if="showElement"
role="toolbar"
aria-orientation="vertical"
:aria-orientation="orientation"
:aria-label="label"
class="grid w-fit gap-1 rounded-(--toolbar-radius) border border-neutral-100 bg-neutral-75/80 p-(--toolbar-p) backdrop-blur-xs [--toolbar-p:0.25rem] [--toolbar-radius:0.75rem] dark:border-gray-900 dark:bg-gray-500/80"
:class="{ 'grid-flow-col items-center': orientation === 'horizontal' }"
>
<Transition :name="transitions.collapseHeight">
<slot />
<Transition v-if="!hasGenericActions" :name="transitions.collapseHeight">
<div v-if="!hidePrimaryAction && slots['primary-action']" class="flex">
<slot name="primary-action" />
</div>
</Transition>
<Transition :name="transitions.collapseHeight">
<Transition v-if="!hasGenericActions" :name="transitions.collapseHeight">
<div v-if="!isReachingTop">
<div class="flex min-h-0">
<CommonButton
@@ -70,7 +86,7 @@ const { transitions } = useTransitionConfig()
</div>
</Transition>
<Transition :name="transitions.collapseHeight">
<Transition v-if="!hasGenericActions" :name="transitions.collapseHeight">
<div v-if="!isReachingBottom">
<div class="relative flex min-h-0">
<CommonButton

View File

@@ -105,4 +105,36 @@ describe('CommonFloatingToolbar', () => {
expect(wrapper.emitted('scroll-to-unread')).toHaveLength(1)
})
describe('with generic actions', () => {
const renderGenericToolbar = (props: Partial<Props> = {}) =>
renderComponent(CommonFloatingToolbar, {
props: {
label: 'Image actions',
orientation: 'horizontal',
...props,
},
slots: {
default: '<button>Zoom in</button>',
},
})
it('renders the given actions in place of the scroll controls', () => {
const wrapper = renderGenericToolbar()
expect(wrapper.getByRole('toolbar', { name: 'Image actions' })).toHaveAttribute(
'aria-orientation',
'horizontal',
)
expect(wrapper.getByRole('button', { name: 'Zoom in' })).toBeVisible()
expect(wrapper.queryByRole('button', { name: 'Scroll to start' })).not.toBeInTheDocument()
expect(wrapper.queryByRole('button', { name: 'Scroll to end' })).not.toBeInTheDocument()
})
it('stays visible independently of the scroll state', () => {
const wrapper = renderGenericToolbar({ isReachingBottom: true, isReachingTop: true })
expect(wrapper.getByRole('toolbar', { name: 'Image actions' })).toBeVisible()
})
})
})

View File

@@ -0,0 +1,110 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { clamp } from 'lodash-es'
import { computed, ref, toRef, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { imageViewerOptions } from '#shared/composables/useImageViewer.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import FloatingToolbar from './FloatingToolbar.vue'
import 'vue-easy-lightbox/dist/external-css/vue-easy-lightbox.css'
// The lightbox tracks the shown image internally and the `index` prop is only its
// initial value, so mirror the navigation to know when an end is reached.
const currentIndex = ref(imageViewerOptions.value.index)
// Showing an image always replaces the options, which also covers reopening the
// viewer on the same image after navigating away from it.
watch(imageViewerOptions, ({ visible, index, images }) => {
if (!visible) return
currentIndex.value = clamp(index, 0, Math.max(images.length - 1, 0))
})
const updateCurrentIndex = (_oldIndex: number, newIndex: number) => {
currentIndex.value = newIndex
}
const isFirstImage = computed(() => currentIndex.value <= 0)
const isLastImage = computed(() => currentIndex.value >= imageViewerOptions.value.images.length - 1)
const localeData = toRef(useLocaleStore(), 'localeData')
const isRtl = computed(() => localeData.value?.dir === 'rtl')
</script>
<template>
<VueEasyLightbox
data-test-id="imageViewer"
:imgs="imageViewerOptions.images"
:index="imageViewerOptions.index"
:visible="imageViewerOptions.visible"
:rtl="isRtl"
@hide="imageViewerOptions.visible = false"
@on-index-change="updateCurrentIndex"
>
<template #toolbar="{ toolbarMethods }">
<FloatingToolbar
@zoom-in="toolbarMethods.zoomIn"
@zoom-out="toolbarMethods.zoomOut"
@resize="toolbarMethods.resize"
@rotate-left="toolbarMethods.rotateLeft"
@rotate-right="toolbarMethods.rotateRight"
/>
</template>
<template #prev-btn="{ prev }">
<CommonButton
v-tooltip="$t('Previous image')"
class="absolute inset-y-1/2 -translate-y-1/2 ltr:left-4 rtl:right-4"
variant="tertiary"
:icon="isRtl ? 'chevron-right' : 'chevron-left'"
size="large"
:disabled="isFirstImage"
@click="prev"
/>
</template>
<template #next-btn="{ next }">
<CommonButton
v-tooltip="$t('Next image')"
class="absolute inset-y-1/2 -translate-y-1/2 ltr:right-4 rtl:left-4"
variant="tertiary"
:icon="isRtl ? 'chevron-left' : 'chevron-right'"
size="large"
:disabled="isLastImage"
@click="next"
/>
</template>
<template #close-btn="{ close }">
<CommonButton
v-tooltip="$t('Close image preview')"
class="absolute top-4 ltr:right-4 rtl:left-4"
variant="tertiary"
size="medium"
icon="x-lg"
@click="close"
/></template>
</VueEasyLightbox>
</template>
<style>
/*
The image viewer (vue-easy-lightbox) appends its own stylesheet to the document head at
runtime, so its rules always come after ours. Overriding them needs `!important`.
The shipped z-index of 9998 would cover the tooltips of its own controls, so keep the
viewer above the app, but below the tooltip layer.
*/
.vel-modal {
z-index: calc(var(--z-index-60) - 1) !important;
background: var(--color-alpha-900) !important;
}
</style>

View File

@@ -0,0 +1,70 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonFloatingToolbar from '#desktop/components/CommonFloatingToolbar/CommonFloatingToolbar.vue'
const emit = defineEmits<{
'zoom-in': []
'zoom-out': []
resize: []
'rotate-left': []
'rotate-right': []
}>()
const buttonClass =
'rounded-[(--toolbar-radius)-(--toolbar-p)]! border! border-neutral-100 text-gray-100 dark:border-gray-900 dark:text-neutral-400'
</script>
<template>
<CommonFloatingToolbar
class="absolute inset-x-1/2 bottom-4 ltr:-translate-x-1/2 rtl:translate-x-1/2"
orientation="horizontal"
:label="$t('Image actions')"
>
<CommonButton
v-tooltip="$t('Zoom in')"
variant="tertiary"
icon="zoom-in"
size="medium"
:class="buttonClass"
@click="emit('zoom-in')"
/>
<CommonButton
v-tooltip="$t('Zoom out')"
variant="tertiary"
icon="zoom-out"
size="medium"
:class="buttonClass"
@click="emit('zoom-out')"
/>
<CommonButton
v-tooltip="$t('Fit to screen')"
variant="tertiary"
icon="arrows-fullscreen"
size="medium"
:class="buttonClass"
@click="emit('resize')"
/>
<CommonButton
v-tooltip="$t('Rotate left')"
variant="tertiary"
icon="arrow-counterclockwise"
size="medium"
:class="buttonClass"
@click="emit('rotate-left')"
/>
<CommonButton
v-tooltip="$t('Rotate right')"
variant="tertiary"
icon="arrow-clockwise"
size="medium"
:class="buttonClass"
@click="emit('rotate-right')"
/>
</CommonFloatingToolbar>
</template>

View File

@@ -0,0 +1,190 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import buildIconsQueries from '#tests/support/components/iconQueries.ts'
import { renderComponent } from '#tests/support/components/index.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { imageViewerOptions } from '#shared/composables/useImageViewer.ts'
import { EnumTextDirection } from '#shared/graphql/types.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import CommonImageViewer from '../CommonImageViewer.vue'
const images = [
{ src: 'https://localhost/first.png', title: 'first.png' },
{ src: 'https://localhost/second.png', title: 'second.png' },
{ src: 'https://localhost/third.png', title: 'third.png' },
]
describe('CommonImageViewer', () => {
afterEach(() => {
imageViewerOptions.value = { visible: false, index: 0, images: [] }
})
it('renders the current image with the image actions toolbar', async () => {
const wrapper = renderComponent(CommonImageViewer)
expect(wrapper.getByTestId('imageViewer')).toBeEmptyDOMElement()
imageViewerOptions.value = {
visible: true,
index: 0,
images: [{ src: 'https://localhost/image.png', title: 'image.png' }],
}
expect(await wrapper.findByRole('toolbar', { name: 'Image actions' })).toBeVisible()
expect(wrapper.getByRole('button', { name: 'Zoom in' })).toBeVisible()
})
it('hides the viewer again on the hide event', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = {
visible: true,
index: 0,
images: [{ src: 'https://localhost/image.png', title: 'image.png' }],
}
await wrapper.events.click(await wrapper.findByRole('button', { name: 'Close image preview' }))
expect(imageViewerOptions.value.visible).toBe(false)
})
describe('image navigation', () => {
it('disables the previous action on the first image', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: 0, images }
expect(await wrapper.findByRole('button', { name: 'Previous image' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(wrapper.getByRole('button', { name: 'Next image' })).not.toHaveAttribute(
'aria-disabled',
)
})
it('disables the next action on the last image', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: images.length - 1, images }
expect(await wrapper.findByRole('button', { name: 'Next image' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(wrapper.getByRole('button', { name: 'Previous image' })).not.toHaveAttribute(
'aria-disabled',
)
})
it('enables both actions while navigating in between', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: 0, images }
await wrapper.events.click(await wrapper.findByRole('button', { name: 'Next image' }))
expect(wrapper.getByRole('button', { name: 'Previous image' })).not.toHaveAttribute(
'aria-disabled',
)
expect(wrapper.getByRole('button', { name: 'Next image' })).not.toHaveAttribute(
'aria-disabled',
)
})
it('disables the next action after navigating to the last image', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: images.length - 2, images }
await wrapper.events.click(await wrapper.findByRole('button', { name: 'Next image' }))
expect(wrapper.getByRole('button', { name: 'Next image' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('resets the navigation state when the viewer is reopened', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: 0, images }
await wrapper.events.click(await wrapper.findByRole('button', { name: 'Next image' }))
imageViewerOptions.value = { visible: false, index: 0, images: [] }
imageViewerOptions.value = { visible: true, index: 0, images }
expect(await wrapper.findByRole('button', { name: 'Previous image' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('reverses the chevrons in RTL locales', async () => {
const wrapper = renderComponent(CommonImageViewer)
const locale = useLocaleStore()
imageViewerOptions.value = { visible: true, index: 1, images }
const previousButton = await wrapper.findByRole('button', { name: 'Previous image' })
const nextButton = wrapper.getByRole('button', { name: 'Next image' })
expect(buildIconsQueries(previousButton).getByIconName('chevron-left')).toBeInTheDocument()
expect(buildIconsQueries(nextButton).getByIconName('chevron-right')).toBeInTheDocument()
locale.localeData = { dir: EnumTextDirection.Rtl } as never
await waitFor(() => {
expect(buildIconsQueries(previousButton).getByIconName('chevron-right')).toBeInTheDocument()
expect(buildIconsQueries(nextButton).getByIconName('chevron-left')).toBeInTheDocument()
})
})
it('navigates forward and backward with arrow keys in LTR locales', async () => {
const wrapper = renderComponent(CommonImageViewer)
imageViewerOptions.value = { visible: true, index: 1, images }
await wrapper.events.keyboard('{ArrowRight}')
expect(await wrapper.findByRole('button', { name: 'Next image' })).toHaveAttribute(
'aria-disabled',
'true',
)
await wrapper.events.keyboard('{ArrowLeft}')
await wrapper.events.keyboard('{ArrowLeft}')
expect(await wrapper.findByRole('button', { name: 'Previous image' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('mirrors arrow key navigation in RTL locales', async () => {
const wrapper = renderComponent(CommonImageViewer)
const locale = useLocaleStore()
locale.localeData = { dir: EnumTextDirection.Rtl } as never
imageViewerOptions.value = { visible: true, index: 1, images }
await wrapper.events.keyboard('{ArrowLeft}')
expect(await wrapper.findByRole('button', { name: 'Next image' })).toHaveAttribute(
'aria-disabled',
'true',
)
await wrapper.events.keyboard('{ArrowRight}')
await wrapper.events.keyboard('{ArrowRight}')
expect(await wrapper.findByRole('button', { name: 'Previous image' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})
})

View File

@@ -0,0 +1,33 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import FloatingToolbar from '../FloatingToolbar.vue'
const renderToolbar = () => renderComponent(FloatingToolbar)
describe('FloatingToolbar', () => {
it('renders all image actions', () => {
const wrapper = renderToolbar()
expect(wrapper.getByRole('toolbar', { name: 'Image actions' })).toBeVisible()
;['Zoom in', 'Zoom out', 'Fit to screen', 'Rotate left', 'Rotate right'].forEach((name) => {
expect(wrapper.getByRole('button', { name })).toBeVisible()
})
})
it.each([
['Zoom in', 'zoom-in'],
['Zoom out', 'zoom-out'],
['Fit to screen', 'resize'],
['Rotate left', 'rotate-left'],
['Rotate right', 'rotate-right'],
])('emits %s action', async (name, event) => {
const wrapper = renderToolbar()
await wrapper.events.click(wrapper.getByRole('button', { name }))
expect(wrapper.emitted(event)).toHaveLength(1)
})
})

View File

@@ -4,6 +4,7 @@
import { onClickOutside, onLongPress, useElementHover, whenever } from '@vueuse/core'
import { computed, onDeactivated, onUnmounted, shallowRef, watch } from 'vue'
import type { Link } from '#shared/types/router.ts'
import getUuid from '#shared/utils/getUuid.ts'
import CommonPopover, {
@@ -12,7 +13,7 @@ import CommonPopover, {
import { usePopover } from '#desktop/components/CommonPopover/usePopover.ts'
export interface Props extends Omit<CommonPopoverProps, 'owner'> {
triggerLink?: string
triggerLink?: Link
triggerLinkClass?: string
triggerLinkActiveClass?: string
/**

View File

@@ -5,7 +5,9 @@ import { computed } from 'vue'
import CommonIcon from '#shared/components/CommonIcon/CommonIcon.vue'
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import type { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { visibilityMeta } from './visibilityMeta.ts'
interface Props {
// Optional so incomplete answer data (e.g. auto-mocked GraphQL results in tests) renders no state
@@ -17,35 +19,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { size: 'small' })
// One icon per publication state, each with its own color. Keeping icon and color together here
// means both the knowledge base answer list and the AI suggestions in the ticket sidebar stay in
// sync, and `satisfies` makes the compiler flag a state that is missing an icon.
const visibilityMeta = {
[EnumKnowledgeBaseVisibility.Draft]: {
icon: 'kb-draft',
label: __('Draft'),
class: 'text-neutral-500! dark:text-stone-200!',
},
[EnumKnowledgeBaseVisibility.Internal]: {
icon: 'kb-internal',
label: __('Internal'),
class: 'text-blue-800!',
},
[EnumKnowledgeBaseVisibility.Published]: {
icon: 'kb-published',
label: __('Published'),
class: 'text-green-400!',
},
[EnumKnowledgeBaseVisibility.Archived]: {
icon: 'kb-archived',
label: __('Archived'),
class: 'text-stone-400!',
},
} as const satisfies Record<
EnumKnowledgeBaseVisibility,
{ icon: string; label: string; class: string }
>
// Resolves to `undefined` when no visibility is given, so the template renders no icon rather than
// crashing on incomplete data.
const meta = computed(() => (props.visibility ? visibilityMeta[props.visibility] : undefined))

View File

@@ -0,0 +1,33 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
// One icon per publication state, each with its own color and label. Keeping icon, color and
// label together here means the knowledge base answer list, the AI suggestions in the ticket
// sidebar and the answer header badge stay in sync, and `satisfies` makes the compiler flag a
// state that is missing an entry.
export const visibilityMeta = {
[EnumKnowledgeBaseVisibility.Draft]: {
icon: 'kb-draft',
label: __('Draft'),
class: 'text-neutral-500! dark:text-stone-200!',
},
[EnumKnowledgeBaseVisibility.Internal]: {
icon: 'kb-internal',
label: __('Internal'),
class: 'text-blue-800!',
},
[EnumKnowledgeBaseVisibility.Published]: {
icon: 'kb-published',
label: __('Published'),
class: 'text-green-400!',
},
[EnumKnowledgeBaseVisibility.Archived]: {
icon: 'kb-archived',
label: __('Archived'),
class: 'text-stone-400!',
},
} as const satisfies Record<
EnumKnowledgeBaseVisibility,
{ icon: string; label: string; class: string }
>

View File

@@ -40,6 +40,11 @@ export interface Props {
*/
showInlineHelp?: boolean
showSidebar?: boolean
/**
* Identifies the content sidebar's collapsed state and persisted width, so
* sections using this layout do not share them.
*/
sidebarName?: SidebarName
noPadding?: boolean
/**
* Removes padding from main container
@@ -62,6 +67,7 @@ const props = withDefaults(defineProps<Props>(), {
showInlineHelp: false,
contentAlignment: 'start',
activeTab: '',
sidebarName: SidebarName.TicketContent,
})
const maxWidth = computed(() => (props.width === 'narrow' ? '600px' : undefined))
@@ -79,7 +85,7 @@ const {
gridColumns,
resizeSidebar,
resetSidebarWidth,
} = useResizeGridColumns(SidebarName.TicketContent, SidebarPosition.End)
} = useResizeGridColumns(props.sidebarName, SidebarPosition.End)
const { transitions } = useTransitionConfig()
@@ -88,7 +94,7 @@ const { isSmallScreen } = useAppBreakpoints()
const { isSidebarCollapsed: isPrimaryNavSidebarCollapsed } = useSidebarDisplay(SidebarName.Primary)
const { isSidebarCollapsed: isContentSidebarCollapsed, toggleSidebar: toggleContentSidebar } =
useSidebarDisplay(SidebarName.TicketContent)
useSidebarDisplay(props.sidebarName)
// When the primary nav expands on a small screen (<1024px), collapse the content sidebar.
watch(isPrimaryNavSidebarCollapsed, (isCollapsed) => {
@@ -184,7 +190,7 @@ const { hasReducedMotion } = useReducedMotion()
v-if="$slots.sideBar"
v-show="showSidebar"
id="content-sidebar"
:name="SidebarName.TicketContent"
:name="sidebarName"
:position="SidebarPosition.End"
:aria-label="$t('Content sidebar')"
collapsible

View File

@@ -8,7 +8,6 @@ import { useActivityMessage } from '#shared/composables/activity-message/useActi
import type { OnlineNotification } from '#shared/graphql/types.ts'
import AiAgentAvatar from '#desktop/components/AiAgent/AiAgentAvatar.vue'
import { initializeBetaUi } from '#desktop/components/BetaUi/composables/useBetaUi.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
interface Props {
@@ -24,24 +23,10 @@ const emit = defineEmits<{
}>()
const { link, builder, highlightedMessage } = useActivityMessage(toRef(props, 'notification'))
const { clearSwitchAndRedirect } = initializeBetaUi()
const handleLinkClick = (event: Event, notification: OnlineNotification) => {
if (link && link.startsWith('#') && link.length > 1) {
event.preventDefault()
emit('visited', notification)
clearSwitchAndRedirect(`/${link}`)
return
}
if (link) {
emit('visited', notification)
return
}
if (notification.seen) return
emit('seen', notification)
const handleLinkClick = (notification: OnlineNotification) => {
if (link) emit('visited', notification)
if (!notification.seen) emit('seen', notification)
}
</script>
@@ -59,7 +44,7 @@ const handleLinkClick = (event: Event, notification: OnlineNotification) => {
'cursor-pointer': !notification.seen,
}"
:link="link ? `/${link}` : undefined"
@click="handleLinkClick($event, notification)"
@click="handleLinkClick(notification)"
>
<AiAgentAvatar v-if="notification?.meta?.createdByAi" class="col-start-1 row-span-2" />
<CommonUserAvatar

View File

@@ -2,6 +2,7 @@
import { generateObjectData } from '#tests/graphql/builders/index.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import type { OnlineNotificationConnection } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
@@ -9,6 +10,24 @@ import { edgesToArray } from '#shared/utils/helpers.ts'
import NotificationList from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover/NotificationList.vue'
import type { RouteRecordRaw } from 'vue-router'
// The router only initializes once per spec file (see renderComponent.ts), so every `router:
// true` call below shares this same route set - passing it just on the last one would be
// silently ignored.
const routerRoutes: RouteRecordRaw[] = [
{
path: '/:pathMatch(.*)*',
name: 'Error',
component: { template: '<div />' },
},
{
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId(\\d+)',
name: 'KnowledgeBaseAnswer',
component: { template: '<div />' },
},
]
const defaults = {
edges: [
{
@@ -136,6 +155,7 @@ describe('NotificationList', () => {
),
},
router: true,
routerRoutes,
})
const notificationsItems = wrapper.getAllByRole('listitem')
@@ -194,6 +214,7 @@ describe('NotificationList', () => {
list,
},
router: true,
routerRoutes,
})
await wrapper.events.click(
@@ -211,6 +232,44 @@ describe('NotificationList', () => {
expect(wrapper.emitted('remove')).toEqual([[list[0]]])
})
it('marks an unseen notification as seen and emits visited when its link is clicked', async () => {
const list = edgesToArray(
generateObjectData<OnlineNotificationConnection>('OnlineNotificationConnection', defaults),
)
// The second entry (id 3, "GitLab") is unseen and has a real link (a ticket).
const notification = list[1]
const wrapper = renderComponent(NotificationList, {
props: { list },
router: true,
routerRoutes,
})
await wrapper.events.click(wrapper.getByRole('link', { name: /GitLab/ }))
expect(wrapper.emitted('visited')).toEqual([[notification]])
expect(wrapper.emitted('seen')).toEqual([[notification]])
})
it('does not re-emit seen for a notification that is already seen', async () => {
const list = edgesToArray(
generateObjectData<OnlineNotificationConnection>('OnlineNotificationConnection', defaults),
)
// The first entry (id 1, "Bunch of articles") is already seen.
const notification = list[0]
const wrapper = renderComponent(NotificationList, {
props: { list },
router: true,
routerRoutes,
})
await wrapper.events.click(wrapper.getByRole('link', { name: /Bunch of articles/ }))
expect(wrapper.emitted('visited')).toEqual([[notification]])
expect(wrapper.emitted('seen')).toBeUndefined()
})
it('displays inaccessible notification message', async () => {
const wrapper = renderComponent(NotificationList, {
props: {
@@ -248,6 +307,11 @@ describe('NotificationList', () => {
})
it('displays AI agent notification', async () => {
// The knowledge-base-answer builder checks useSessionStore().hasPermission('knowledge_base.*')
// to decide between the internal route and the public help page - without a mocked
// permission it defaults to no access and falls back to the help page.
mockPermissions(['knowledge_base.*'])
const wrapper = renderComponent(NotificationList, {
props: {
list: edgesToArray(
@@ -289,9 +353,6 @@ describe('NotificationList', () => {
id: convertToGraphQLId('KnowledgeBase::Answer', 6),
category: {
id: convertToGraphQLId('KnowledgeBase::Category', 1),
knowledgeBase: {
id: convertToGraphQLId('KnowledgeBase', 2),
},
},
},
},
@@ -307,8 +368,15 @@ describe('NotificationList', () => {
),
},
router: true,
routerRoutes,
store: true,
})
expect(wrapper.getByLabelText('AI agent')).toBeInTheDocument()
expect(wrapper.getByRole('link')).toHaveAttribute(
'href',
'/desktop/knowledge-base/locale/en-us/answer/6',
)
})
})

View File

@@ -14,6 +14,7 @@ const defaultCollapsedState = () => ({
[SidebarName.TicketContent]: false,
[SidebarName.TicketOverviews]: false,
[SidebarName.PersonalSetting]: false,
[SidebarName.KnowledgeBaseAnswer]: false,
})
export const useSidebarDisplayStore = defineStore('sidebarDisplay', () => {

View File

@@ -18,6 +18,7 @@ export enum SidebarName {
TicketContent = 'ticket-content',
TicketOverviews = 'ticket-overviews',
PersonalSetting = 'personal-setting',
KnowledgeBaseAnswer = 'knowledge-base-answer',
}
export interface ToggleOptions {

View File

@@ -0,0 +1,38 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { knowledgeBaseAnswerRoute, knowledgeBaseBrowseRoute } from '../utils/routeLocation.ts'
describe('knowledgeBaseBrowseRoute', () => {
it('targets the section entry without a locale', () => {
expect(knowledgeBaseBrowseRoute()).toEqual({ name: 'KnowledgeBaseBrowse', params: {} })
})
it('targets the localized root without a category', () => {
expect(knowledgeBaseBrowseRoute('en-us')).toEqual({
name: 'KnowledgeBaseBrowse',
params: { localeCode: 'en-us' },
})
})
it('uses the internal id in the category URL', () => {
expect(
knowledgeBaseBrowseRoute('en-us', convertToGraphQLId('KnowledgeBase::Category', 42)),
).toEqual({
name: 'KnowledgeBaseCategory',
params: { localeCode: 'en-us', categoryInternalId: 42 },
})
})
})
describe('knowledgeBaseAnswerRoute', () => {
it('uses the internal id in the answer URL', () => {
expect(
knowledgeBaseAnswerRoute('de-de', convertToGraphQLId('KnowledgeBase::Answer', 7)),
).toEqual({
name: 'KnowledgeBaseAnswer',
params: { localeCode: 'de-de', answerInternalId: 7 },
})
})
})

View File

@@ -0,0 +1,44 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
export const KnowledgeBaseAnswerAttributesFragmentDoc = gql`
fragment knowledgeBaseAnswerAttributes on KnowledgeBaseAnswer {
title
content {
id
bodyWithUrls
}
visibility
translationId
translationMissing
internalAt
publishedAt
archivedAt
editedAt
editedBy {
id
firstname
lastname
fullname
}
category {
id
breadcrumb {
id
title
categoryIcon
iconSet
visibility
}
}
tags
attachments {
id
internalId
name
size
type
preferences
}
}
`;

View File

@@ -0,0 +1,39 @@
fragment knowledgeBaseAnswerAttributes on KnowledgeBaseAnswer {
title
content {
id
bodyWithUrls
}
visibility
translationId
translationMissing
internalAt
publishedAt
archivedAt
editedAt
editedBy {
id
firstname
lastname
fullname
}
category {
id
breadcrumb {
id
title
categoryIcon
iconSet
visibility
}
}
tags
attachments {
id
internalId
name
size
type
preferences
}
}

View File

@@ -22,9 +22,6 @@ export const KnowledgeBaseAnswerTranslationFragmentDoc = gql`
category {
id
title
knowledgeBase {
id
}
}
}
kbLocale {

View File

@@ -18,9 +18,6 @@ fragment knowledgeBaseAnswerTranslation on KnowledgeBaseAnswerTranslation {
category {
id
title
knowledgeBase {
id
}
}
}
kbLocale {

View File

@@ -9,6 +9,7 @@ export const KnowledgeBaseCategoryPreInfoFragmentDoc = gql`
id
title
categoryIcon
iconSet
visibility
}
}

View File

@@ -8,6 +8,7 @@ fragment knowledgeBaseCategoryPreInfo on KnowledgeBaseCategory {
id
title
categoryIcon
iconSet
visibility
}
}

View File

@@ -0,0 +1,35 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { KnowledgeBaseAnswerAttributesFragmentDoc } from '../fragments/knowledgeBaseAnswerAttributes.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const KnowledgeBaseAnswerDocument = gql`
query knowledgeBaseAnswer($answerId: ID!, $locale: String) {
knowledgeBaseAnswer(answerId: $answerId, locale: $locale) {
id
...knowledgeBaseAnswerAttributes
navigation {
index
totalCount
previousAnswer {
id
title
}
nextAnswer {
id
title
}
}
}
}
${KnowledgeBaseAnswerAttributesFragmentDoc}`;
export function useKnowledgeBaseAnswerQuery(variables: Types.KnowledgeBaseAnswerQueryVariables | VueCompositionApi.Ref<Types.KnowledgeBaseAnswerQueryVariables> | ReactiveFunction<Types.KnowledgeBaseAnswerQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>(KnowledgeBaseAnswerDocument, variables, options);
}
export function useKnowledgeBaseAnswerLazyQuery(variables?: Types.KnowledgeBaseAnswerQueryVariables | VueCompositionApi.Ref<Types.KnowledgeBaseAnswerQueryVariables> | ReactiveFunction<Types.KnowledgeBaseAnswerQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>(KnowledgeBaseAnswerDocument, variables, options);
}
export type KnowledgeBaseAnswerQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>;

View File

@@ -0,0 +1,18 @@
query knowledgeBaseAnswer($answerId: ID!, $locale: String) {
knowledgeBaseAnswer(answerId: $answerId, locale: $locale) {
id
...knowledgeBaseAnswerAttributes
navigation {
index
totalCount
previousAnswer {
id
title
}
nextAnswer {
id
title
}
}
}
}

View File

@@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './knowledgeBaseAnswer.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockKnowledgeBaseAnswerQuery(defaults: Mocks.MockDefaultsValue<Types.KnowledgeBaseAnswerQuery, Types.KnowledgeBaseAnswerQueryVariables>) {
return Mocks.mockGraphQLResult(Operations.KnowledgeBaseAnswerDocument, defaults)
}
export function waitForKnowledgeBaseAnswerQueryCalls() {
return Mocks.waitForGraphQLMockCalls<Types.KnowledgeBaseAnswerQuery>(Operations.KnowledgeBaseAnswerDocument)
}
export function mockKnowledgeBaseAnswerQueryError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.KnowledgeBaseAnswerDocument, message, extensions);
}

View File

@@ -0,0 +1,26 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { KnowledgeBaseAnswerAttributesFragmentDoc } from '../fragments/knowledgeBaseAnswerAttributes.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const KnowledgeBaseAnswerUpdatesDocument = gql`
subscription knowledgeBaseAnswerUpdates($answerId: ID!, $locale: String, $initial: Boolean = false) {
knowledgeBaseAnswerUpdates(
answerId: $answerId
locale: $locale
initial: $initial
) {
answer {
id
...knowledgeBaseAnswerAttributes
}
}
}
${KnowledgeBaseAnswerAttributesFragmentDoc}`;
export function useKnowledgeBaseAnswerUpdatesSubscription(variables: Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables | VueCompositionApi.Ref<Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables> | ReactiveFunction<Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables>, options: VueApolloComposable.UseSubscriptionOptions<Types.KnowledgeBaseAnswerUpdatesSubscription, Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables> | VueCompositionApi.Ref<VueApolloComposable.UseSubscriptionOptions<Types.KnowledgeBaseAnswerUpdatesSubscription, Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables>> | ReactiveFunction<VueApolloComposable.UseSubscriptionOptions<Types.KnowledgeBaseAnswerUpdatesSubscription, Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables>> = {}) {
return VueApolloComposable.useSubscription<Types.KnowledgeBaseAnswerUpdatesSubscription, Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables>(KnowledgeBaseAnswerUpdatesDocument, variables, options);
}
export type KnowledgeBaseAnswerUpdatesSubscriptionCompositionFunctionResult = VueApolloComposable.UseSubscriptionReturn<Types.KnowledgeBaseAnswerUpdatesSubscription, Types.KnowledgeBaseAnswerUpdatesSubscriptionVariables>;

View File

@@ -0,0 +1,12 @@
subscription knowledgeBaseAnswerUpdates(
$answerId: ID!
$locale: String
$initial: Boolean = false
) {
knowledgeBaseAnswerUpdates(answerId: $answerId, locale: $locale, initial: $initial) {
answer {
id
...knowledgeBaseAnswerAttributes
}
}
}

View File

@@ -0,0 +1,9 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './knowledgeBaseAnswerUpdates.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function getKnowledgeBaseAnswerUpdatesSubscriptionHandler() {
return Mocks.getGraphQLSubscriptionHandler<Types.KnowledgeBaseAnswerUpdatesSubscription>(Operations.KnowledgeBaseAnswerUpdatesDocument)
}

View File

@@ -8,6 +8,7 @@ import { SubscriptionHandler } from '#shared/server/apollo/handler/index.ts'
import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
import { useLastVisitedPath } from '#desktop/composables/useLastVisitedPath.ts'
import type { KnowledgeBaseIconSet } from '#desktop/pages/knowledge-base/types.ts'
import { useKnowledgeBaseQuery } from '../graphql/queries/knowledgeBase.api'
import { useKnowledgeBaseContentUpdatesSubscription } from '../graphql/subscriptions/knowledgeBaseContentUpdates.api'
@@ -70,11 +71,20 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
// carry affected category ids and are handled by the browse queries.
if (data?.knowledgeBaseContentUpdates?.affectedCategoryIds?.length) return
knowledgeBaseQuery.refetch()
// Pin the refetch to the current locale explicitly: Vue only pushes a
// changed route locale into the underlying query on its next reactivity
// flush, so a ping arriving in the same tick as a locale switch would
// otherwise refetch with the locale being navigated away from.
knowledgeBaseQuery.refetch({ locale: activeLocale.value })
})
const iconSet = computed(
() => (knowledgeBase.value?.iconset ?? 'FontAwesome') as KnowledgeBaseIconSet,
)
return {
knowledgeBase,
iconSet,
loading,
activeLocale,
kbLocales,

View File

@@ -6,7 +6,7 @@ import type { RouteLocationNamedRaw } from 'vue-router'
// The knowledge base root listing and a single category are two separate routes
// — a category carries `/category/:id` in the URL (mirroring the `/answer/:id`
// route added later). This picks the right target from what a caller has, so
// route below). This picks the right target from what a caller has, so
// callers don't juggle the two route names and params themselves. Omitting the
// locale targets the section entry, whose guard resolves the user's preferred
// locale (falling back to the primary, or not-found when there is none).
@@ -25,3 +25,13 @@ export const knowledgeBaseBrowseRoute = (
return { name: 'KnowledgeBaseBrowse', params: localeCode ? { localeCode } : {} }
}
// An answer is addressed by its locale alone - it does not sit below the
// category it was browsed from (internal id in the URL, as above).
export const knowledgeBaseAnswerRoute = (
localeCode: string,
answerId: string,
): RouteLocationNamedRaw => ({
name: 'KnowledgeBaseAnswer',
params: { localeCode, answerInternalId: getIdFromGraphQLId(answerId) },
})

View File

@@ -1,7 +1,7 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { flushPromises } from '@vue/test-utils'
import { effectScope, ref, toRef } from 'vue'
import { EffectScope, effectScope, ref, toRef } from 'vue'
import { waitForNextTick } from '#tests/support/utils.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
@@ -16,9 +16,17 @@ import {
} from '../../graphql/queries/linkList.mocks.ts'
import { useObjectLinks } from '../useObjectLinks.ts'
const scope = effectScope()
describe('useObjectLinks', () => {
let scope: EffectScope
beforeEach(() => {
scope = effectScope()
})
afterEach(() => {
scope.stop()
})
it('returns types with links', async () => {
await scope.run(async () => {
mockLinkListQuery({

View File

@@ -3,9 +3,9 @@
import { computed } from 'vue'
import {
type LinkListQuery,
type LinkUpdatesSubscriptionVariables,
type LinkUpdatesSubscription,
type LinkListQuery,
} from '#shared/graphql/types.ts'
import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
@@ -33,7 +33,7 @@ export const useObjectLinks = (
objectId: objectId.value,
targetType,
}),
() => ({ enabled: options.enabled?.value ?? true }),
() => ({ enabled: Boolean(objectId.value) && (options.enabled?.value ?? true) }),
),
)
@@ -46,8 +46,9 @@ export const useObjectLinks = (
objectId: objectId.value,
targetType,
},
updateQuery: (_, { subscriptionData }) => {
updateQuery: (previous, { subscriptionData }) => {
if (!subscriptionData.data?.linkUpdates.links) {
// Always return null when we need not change anything related to the data.
return null as unknown as LinkListQuery
}

View File

@@ -1,7 +1,7 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { KnowledgeBaseAnswerTranslationFragmentDoc } from '../../../../entities/knowledge-base/graphql/fragments/knowledgeBaseAnswerTranslation.api';
import { KnowledgeBaseAnswerTranslationFragmentDoc } from '../../../knowledge-base/graphql/fragments/knowledgeBaseAnswerTranslation.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;

View File

@@ -1,7 +1,7 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { KnowledgeBaseAnswerTranslationFragmentDoc } from '../../../../entities/knowledge-base/graphql/fragments/knowledgeBaseAnswerTranslation.api';
import { KnowledgeBaseAnswerTranslationFragmentDoc } from '../../../knowledge-base/graphql/fragments/knowledgeBaseAnswerTranslation.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;

View File

@@ -0,0 +1,18 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { tagSearchRoute } from '../routeLocation.ts'
describe('tagSearchRoute', () => {
it('targets the search with the tag as a filter term', () => {
expect(tagSearchRoute('vip')).toEqual({
name: 'Search',
params: { searchTerm: 'tags:"vip"' },
query: { entity: 'Ticket' }, // TODO: Update once tag search is available
})
})
// Without the quotes the search would split the tag into separate terms.
it('keeps a multi-word tag as a single term', () => {
expect(tagSearchRoute('second level').params?.searchTerm).toBe('tags:"second level"')
})
})

View File

@@ -0,0 +1,14 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import type { RouteLocationNamedRaw } from 'vue-router'
// Clicking a tag hands the search a prepared term instead of a dedicated route
// of its own.
// TODO: Add proper quick search handler for tag search when ready.
// TODO: The entity is pinned to tickets until the knowledge base is searchable
// as its own entity.
export const tagSearchRoute = (tag: string): RouteLocationNamedRaw => ({
name: 'Search',
params: { searchTerm: `tags:"${tag}"` },
query: { entity: 'Ticket' },
})

View File

@@ -1,12 +1,16 @@
# Third Party Icons (`desktop`)
- `assets/archive-fill.svg`
- `assets/arrow-left.svg`
- `assets/arrow-right.svg`
- `assets/arrow-bar-left.svg`
- `assets/arrow-bar-right.svg`
- `assets/arrow-clockwise.svg`
- `assets/arrow-counterclockwise.svg`
- `assets/arrow-repeat.svg`
- `assets/arrows-collapse.svg`
- `assets/arrows-expand.svg`
- `assets/arrows-fullscreen.svg`
- `assets/book.svg`
- `assets/box-arrow-in-right.svg`
- `assets/box-arrow-up-right.svg`
@@ -102,6 +106,8 @@
- `assets/x-circle.svg`
- `assets/x-lg.svg`
- `assets/x.svg`
- `assets/zoom-in.svg`
- `assets/zoom-out.svg`
- Author: The Bootstrap Authors
- License: MIT
- URL: <https://github.com/twbs/icons>

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6429 15C13.9789 15 15 13.8452 15 12.5V5H1V12.5C1 13.8452 2.02108 15 3.35714 15H12.6429ZM5.5 7H10.5C10.7761 7 11 7.22386 11 7.5C11 7.77614 10.7761 8 10.5 8H5.5C5.22386 8 5 7.77614 5 7.5C5 7.22386 5.22386 7 5.5 7Z" fill="black"/>
<path d="M0.8 1C0.358172 1 0 1.35817 0 1.8V3C0 3.44183 0.358172 3.8 0.8 3.8H15.2C15.6418 3.8 16 3.44183 16 3V1.8C16 1.35817 15.6418 1 15.2 1H0.8Z" />
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z" />
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466" />
</svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z" />
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466" />
</svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707m0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707" />
</svg>

After

Width:  |  Height:  |  Size: 674 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11M13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0" />
<path d="M10.344 11.742q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1 6.5 6.5 0 0 1-1.398 1.4" />
<path fill-rule="evenodd" d="M6.5 3a.5.5 0 0 1 .5.5V6h2.5a.5.5 0 0 1 0 1H7v2.5a.5.5 0 0 1-1 0V7H3.5a.5.5 0 0 1 0-1H6V3.5a.5.5 0 0 1 .5-.5" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11M13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0" />
<path d="M10.344 11.742q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1 6.5 6.5 0 0 1-1.398 1.4" />
<path fill-rule="evenodd" d="M3 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5" />
</svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@@ -51,6 +51,8 @@ import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
import SplitButton from '#desktop/components/SplitButton/SplitButton.vue'
import ThemeSwitch from '#desktop/components/ThemeSwitch/ThemeSwitch.vue'
import UserPopoverWithTrigger from '#desktop/components/User/UserPopoverWithTrigger.vue'
// eslint-disable-next-line import/no-restricted-paths
import KnowledgeBaseCategoryIcon from '#desktop/pages/knowledge-base/components/KnowledgeBaseCategoryIcon.vue'
const alphabetOptions = computed(() =>
[...Array(26).keys()].map((i) => ({
@@ -1417,6 +1419,14 @@ const { openFeedbackDialog } = useFeedbackDialog()
<template>
<LayoutContent>
<div>
<h3>Knowledge Base Category Icons</h3>
<div class="mb-4 flex space-x-2 text-blue-800">
<KnowledgeBaseCategoryIcon name="e699" set="anticon" size="small" />
<KnowledgeBaseCategoryIcon name="f115" set="FontAwesome" size="small" />
<KnowledgeBaseCategoryIcon name="f38a" set="ionicons" size="small" />
<KnowledgeBaseCategoryIcon name="e94d" set="material" size="small" />
<KnowledgeBaseCategoryIcon name="e039" set="Simple-Line-Icons" size="small" />
</div>
<h3>Notifications / Alerts</h3>
<div class="mb-4 space-x-2">
<CommonButton

View File

@@ -0,0 +1,162 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { imageViewerOptions } from '#shared/composables/useImageViewer.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import type { KnowledgeBaseAnswerQuery } from '#shared/graphql/types.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { mockKnowledgeBaseQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBase.mocks.ts'
import {
mockKnowledgeBaseAnswerQuery,
waitForKnowledgeBaseAnswerQueryCalls,
} from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBaseAnswer.mocks.ts'
const ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 5)
const CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 1)
const CONTENT_ID = convertToGraphQLId('KnowledgeBase::Answer::Translation::Content', 5)
const ANSWER_PATH: `/${string}` = `/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(ANSWER_ID)}`
type Answer = NonNullable<KnowledgeBaseAnswerQuery['knowledgeBaseAnswer']>
const attachment = (internalId: number, name: string, type: string) => ({
__typename: 'StoredFile' as const,
id: convertToGraphQLId('Store', internalId),
internalId,
name,
type,
size: 248_000,
preferences: { 'Content-Type': type },
})
const mockAnswer = (attachments: Answer['attachments']) =>
mockKnowledgeBaseAnswerQuery({
knowledgeBaseAnswer: {
id: ANSWER_ID,
title: 'Some Knowledge Base Answer',
content: {
__typename: 'KnowledgeBaseAnswerTranslationContent',
id: CONTENT_ID,
bodyWithUrls: '<p>Some answer body.</p>',
},
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
internalAt: null,
publishedAt: '2026-08-01T10:00:00Z',
archivedAt: null,
editedAt: null,
editedBy: null,
navigation: null,
attachments,
category: {
id: CATEGORY_ID,
breadcrumb: [
{
id: CATEGORY_ID,
title: 'Root Category',
categoryIcon: 'folder',
visibility: EnumKnowledgeBaseVisibility.Published,
},
],
},
},
} as KnowledgeBaseAnswerQuery)
describe('knowledge base answer attachments', () => {
beforeEach(() => {
mockApplicationConfig({
api_path: '/api/v1',
kb_active_publicly: true,
'active_storage.content_types_allowed_inline': ['image/png', 'image/jpeg'],
})
mockPermissions(['knowledge_base.reader'])
mockKnowledgeBaseQuery({
knowledgeBase: {
id: convertToGraphQLId('KnowledgeBase', 1),
title: 'My Knowledge Base',
iconset: 'default',
isPubliclyAvailable: true,
isVisiblePublicly: true,
kbLocales: [
{
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
primary: true,
systemLocale: { id: '1', locale: 'en-us', name: 'English (United States)' },
},
],
currentLocale: {
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
systemLocale: { id: '1', locale: 'en-us' },
},
},
})
})
it('lists the attachments of the answer below its body', async () => {
mockAnswer([
attachment(1, 'IMG_1234.png', 'image/png'),
attachment(2, 'SomeMeetingFile.ics', 'text/calendar'),
attachment(3, 'SomeTableFile.csv', 'text/csv'),
])
const view = await visitView(ANSWER_PATH)
const list = await view.findByRole('list', { name: 'Attachments' })
expect(list.querySelectorAll('li')).toHaveLength(3)
expect(view.getByText('IMG_1234')).toBeInTheDocument()
expect(view.getByText('SomeMeetingFile')).toBeInTheDocument()
expect(view.getByText('SomeTableFile')).toBeInTheDocument()
// The body comes first, the attachments after it.
expect(
list.compareDocumentPosition(view.getByText('Some answer body.')) &
Node.DOCUMENT_POSITION_PRECEDING,
).toBeTruthy()
})
it('builds the download URL from the internal id', async () => {
mockAnswer([attachment(3, 'SomeTableFile.csv', 'text/csv')])
const view = await visitView(ANSWER_PATH)
expect(await view.findByRole('link', { name: 'Download SomeTableFile.csv' })).toHaveAttribute(
'href',
'/api/v1/attachments/3?disposition=attachment',
)
})
it('renders nothing when the answer has no attachments', async () => {
mockAnswer([])
const view = await visitView(ANSWER_PATH)
await waitForKnowledgeBaseAnswerQueryCalls()
await waitFor(() => {
expect(view.queryByRole('list', { name: 'Attachments' })).not.toBeInTheDocument()
})
})
it('opens an image attachment in the image viewer', async () => {
mockAnswer([attachment(1, 'IMG_1234.png', 'image/png')])
const view = await visitView(ANSWER_PATH)
await view.events.click(await view.findByRole('button', { name: 'Preview IMG_1234.png' }))
await waitFor(() => {
expect(imageViewerOptions.value.visible).toBe(true)
})
expect(imageViewerOptions.value.images.at(0)).toEqual(
expect.objectContaining({ title: 'IMG_1234.png' }),
)
})
})

View File

@@ -0,0 +1,183 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { imageViewerOptions } from '#shared/composables/useImageViewer.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import type { KnowledgeBaseAnswerQuery } from '#shared/graphql/types.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { mockKnowledgeBaseQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBase.mocks.ts'
import {
mockKnowledgeBaseAnswerQuery,
waitForKnowledgeBaseAnswerQueryCalls,
} from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBaseAnswer.mocks.ts'
const ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 5)
const CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 1)
const CONTENT_ID = convertToGraphQLId('KnowledgeBase::Answer::Translation::Content', 5)
const ANSWER_PATH: `/${string}` = `/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(ANSWER_ID)}`
type Answer = NonNullable<KnowledgeBaseAnswerQuery['knowledgeBaseAnswer']>
const mockAnswer = (content: Answer['content']) =>
mockKnowledgeBaseAnswerQuery({
knowledgeBaseAnswer: {
id: ANSWER_ID,
title: 'Some Knowledge Base Answer',
content,
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
internalAt: null,
publishedAt: '2026-08-01T10:00:00Z',
archivedAt: null,
editedAt: null,
editedBy: null,
navigation: null,
category: {
id: CATEGORY_ID,
breadcrumb: [
{
id: CATEGORY_ID,
title: 'Root Category',
categoryIcon: 'folder',
visibility: EnumKnowledgeBaseVisibility.Published,
},
],
},
},
} as KnowledgeBaseAnswerQuery)
const mockBody = (bodyWithUrls: string) =>
mockAnswer({
__typename: 'KnowledgeBaseAnswerTranslationContent',
id: CONTENT_ID,
bodyWithUrls,
} as Answer['content'])
describe('knowledge base answer content', () => {
beforeEach(() => {
mockApplicationConfig({
kb_active_publicly: true,
// Decides which inline images the image viewer considers previewable.
'active_storage.content_types_allowed_inline': ['image/png', 'image/jpeg'],
})
mockPermissions(['knowledge_base.reader'])
mockKnowledgeBaseQuery({
knowledgeBase: {
id: convertToGraphQLId('KnowledgeBase', 1),
title: 'My Knowledge Base',
iconset: 'default',
isPubliclyAvailable: true,
isVisiblePublicly: true,
kbLocales: [
{
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
primary: true,
systemLocale: { id: '1', locale: 'en-us', name: 'English (United States)' },
},
],
currentLocale: {
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
systemLocale: { id: '1', locale: 'en-us' },
},
},
})
})
it('renders the body of the answer', async () => {
mockBody('<h1>Introduction</h1><p>The following describes our workflow.</p>')
const view = await visitView(ANSWER_PATH)
expect(await view.findByRole('heading', { name: 'Introduction' })).toBeInTheDocument()
expect(view.getByText('The following describes our workflow.')).toBeInTheDocument()
})
it('renders the body with the article content styling hooks', async () => {
mockBody('<h1>Introduction</h1><p>Some <strong>answer</strong> body.</p>')
const view = await visitView(ANSWER_PATH)
await view.findByRole('heading', { name: 'Introduction' })
const body = view.container.querySelector('.Content .inner-article-body')
expect(body).toBeInTheDocument()
expect(body).not.toHaveAttribute('dir')
expect(body?.children[0]).toHaveAttribute('dir', 'auto')
expect(body?.children[1]).toHaveAttribute('dir', 'auto')
expect(body?.querySelector('strong')).toHaveAttribute('dir', 'auto')
})
it('renders no body when the answer has no content', async () => {
mockAnswer(null)
const view = await visitView(ANSWER_PATH)
await waitForKnowledgeBaseAnswerQueryCalls()
// Anchor on the rendered answer, so the absence check runs after the view rendered. Both
// the full and compact headers are mounted, so the title appears more than once.
expect(await view.findAllByText('Some Knowledge Base Answer')).not.toHaveLength(0)
expect(view.container.querySelector('.inner-article-body')).not.toBeInTheDocument()
})
// The server resolves a link to another answer into this app's own answer route (see
// KnowledgeBase::Answer::Translation#desktop_url), so following one stays inside the app
// instead of leaving for the public help page.
it('navigates in-app when a link to another answer is followed', async () => {
mockBody(
'<p>See <a href="/desktop/knowledge-base/locale/en-us/answer/9" data-target-type="knowledge-base-answer" data-target-id="12">the other answer</a>.</p>',
)
const view = await visitView(ANSWER_PATH)
await view.events.click(await view.findByRole('link', { name: 'the other answer' }))
await waitFor(() => {
expect(view).toHaveCurrentUrl('/knowledge-base/locale/en-us/answer/9')
})
})
// The legacy stack addresses an answer as
// `#knowledge_base/<kb id>/locale/<locale>/answer/<answer id>`; links in that shape still
// arrive here (online notifications build it), so the route redirects them.
it('opens the answer from a legacy knowledge base URL', async () => {
mockBody('<h1>Introduction</h1>')
const view = await visitView(
`/knowledge_base/1/locale/en-us/answer/${getIdFromGraphQLId(ANSWER_ID)}`,
)
expect(await view.findByRole('heading', { name: 'Introduction' })).toBeInTheDocument()
expect(view).toHaveCurrentUrl(ANSWER_PATH)
})
it('opens an inline image of the body in the image viewer', async () => {
mockBody('<p>Some answer body.</p><img src="/api/v1/attachments/1" alt="inline.png">')
const view = await visitView(ANSWER_PATH)
await view.findByText('Some answer body.')
const image = view.container.querySelector<HTMLImageElement>('.inner-article-body img')
expect(image).toBeInTheDocument()
await view.events.click(image as HTMLImageElement)
await waitFor(() => {
expect(imageViewerOptions.value.visible).toBe(true)
})
expect(imageViewerOptions.value.images.at(0)).toEqual(
expect.objectContaining({ title: 'inline.png' }),
)
})
})

View File

@@ -0,0 +1,217 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { within } from '@testing-library/vue'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import type { KnowledgeBaseAnswerQuery } from '#shared/graphql/types.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { mockKnowledgeBaseQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBase.mocks.ts'
import { mockKnowledgeBaseAnswerQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBaseAnswer.mocks.ts'
const ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 5)
const PREVIOUS_ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 4)
const NEXT_ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 6)
const ROOT_CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 1)
const CHILD_CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 2)
const ANSWER_PATH = `/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(ANSWER_ID)}`
type Answer = NonNullable<KnowledgeBaseAnswerQuery['knowledgeBaseAnswer']>
const mockAnswer = (overrides: Partial<Answer> = {}) =>
mockKnowledgeBaseAnswerQuery({
knowledgeBaseAnswer: {
id: ANSWER_ID,
title: 'Some Knowledge Base Answer',
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
internalAt: null,
publishedAt: '2026-08-01T10:00:00Z',
archivedAt: null,
editedAt: '2026-08-03T10:00:00Z',
editedBy: {
id: convertToGraphQLId('User', 3),
firstname: 'Erika',
lastname: 'Mustermann',
fullname: 'Erika Mustermann',
organization: null,
},
navigation: {
__typename: 'KnowledgeBaseAnswerNavigation',
index: 2,
totalCount: 3,
previousAnswer: {
__typename: 'KnowledgeBaseAnswer',
id: PREVIOUS_ANSWER_ID,
title: 'Previous Knowledge Base Answer',
},
nextAnswer: {
__typename: 'KnowledgeBaseAnswer',
id: NEXT_ANSWER_ID,
title: 'Next Knowledge Base Answer',
},
},
category: {
id: CHILD_CATEGORY_ID,
breadcrumb: [
{
id: ROOT_CATEGORY_ID,
title: 'Root Category',
categoryIcon: 'folder',
visibility: EnumKnowledgeBaseVisibility.Published,
},
{
id: CHILD_CATEGORY_ID,
title: 'Child Category',
categoryIcon: 'folder',
visibility: EnumKnowledgeBaseVisibility.Published,
},
],
},
...overrides,
},
} as KnowledgeBaseAnswerQuery)
describe('knowledge base answer header', () => {
beforeEach(() => {
mockApplicationConfig({ kb_active_publicly: true })
mockPermissions(['knowledge_base.reader'])
mockKnowledgeBaseQuery({
knowledgeBase: {
id: convertToGraphQLId('KnowledgeBase', 1),
title: 'My Knowledge Base',
iconset: 'default',
isPubliclyAvailable: true,
isVisiblePublicly: true,
kbLocales: [
{
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
primary: true,
systemLocale: { id: '1', locale: 'en-us', name: 'English (United States)' },
},
{
id: convertToGraphQLId('KnowledgeBase::Locale', 2),
primary: false,
systemLocale: { id: '2', locale: 'de-de', name: 'Deutsch' },
},
],
currentLocale: {
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
systemLocale: { id: '1', locale: 'en-us' },
},
},
})
mockAnswer()
})
it('shows the full category path and the answer as the opened node', async () => {
const view = await visitView(ANSWER_PATH)
const breadcrumb = (
await view.findAllByRole('navigation', { name: 'Knowledge base navigation' })
).at(-1)!
// Each link carries its category icon, which contributes to the accessible name.
expect(within(breadcrumb).getByRole('link', { name: /Root Category/ })).toBeInTheDocument()
expect(within(breadcrumb).getByRole('link', { name: /Child Category/ })).toBeInTheDocument()
expect(
within(breadcrumb).getByRole('heading', { name: 'Some Knowledge Base Answer' }),
'the opened answer is the page heading',
).toBeInTheDocument()
})
it('shows the answer title, its publication state and its edit metadata', async () => {
const view = await visitView(ANSWER_PATH)
expect(await view.findAllByText('Published')).not.toHaveLength(0)
expect(view.getAllByText('Published at')).not.toHaveLength(0)
expect(view.getByText(/edited .* by Erika Mustermann/)).toBeInTheDocument()
})
it('links the public knowledge base button to the answer preview endpoint', async () => {
const view = await visitView(ANSWER_PATH)
const links = await view.findAllByRole('link', { name: 'View public knowledge base' })
expect(links.length).toBeGreaterThan(0)
links.forEach((link) => {
expect(link).toHaveAttribute(
'href',
`/api/v1/knowledge_bases/preview/KnowledgeBaseAnswer/${getIdFromGraphQLId(ANSWER_ID)}/en-us`,
)
})
})
it('hides the public knowledge base button for an unpublished answer without editor permission', async () => {
mockAnswer({
visibility: EnumKnowledgeBaseVisibility.Internal,
publishedAt: null,
internalAt: '2026-08-01T10:00:00Z',
})
const view = await visitView(ANSWER_PATH)
// The header rendered, so the button is hidden by visibility, not by an empty page.
expect(await view.findAllByText('Internal')).not.toHaveLength(0)
expect(view.queryByRole('link', { name: 'View public knowledge base' })).not.toBeInTheDocument()
})
it('stays on the answer when switching language', async () => {
const view = await visitView(ANSWER_PATH)
// Both the full and the (scrolled) compact header carry a language selector;
// at the top only the full header is active, so target the last one.
const languageButtons = await view.findAllByRole('button', { name: 'Change language' })
await view.events.click(languageButtons.at(-1) as HTMLElement)
await view.events.click(await view.findByText('Deutsch'))
await waitFor(() => {
expect(view, 'stayed on the answer in the selected locale').toHaveCurrentUrl(
`/knowledge-base/locale/de-de/answer/${getIdFromGraphQLId(ANSWER_ID)}`,
)
})
})
it('navigates to the next answer in the route locale', async () => {
const view = await visitView(ANSWER_PATH)
const nextLinks = await view.findAllByRole('link', {
name: 'Next answer: Next Knowledge Base Answer',
})
await view.events.click(nextLinks.at(-1) as HTMLElement)
await waitFor(() => {
expect(view).toHaveCurrentUrl(
`/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(NEXT_ANSWER_ID)}`,
)
})
})
it('warns when the answer has no translation in the browsed locale', async () => {
mockAnswer({ translationMissing: true })
const view = await visitView(ANSWER_PATH)
// One alert per header, so the warning survives the scroll swap.
await waitFor(() => {
expect(view.getAllByText('No translation for this locale available')).toHaveLength(2)
})
})
it('does not warn about a missing translation for a translated answer', async () => {
const view = await visitView(ANSWER_PATH)
// Both headers are mounted, so the answer title appears more than once.
expect(await view.findAllByText('Some Knowledge Base Answer')).not.toHaveLength(0)
expect(view.queryAllByText('No translation for this locale available')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,113 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { EnumLinkType } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { mockKnowledgeBaseQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBase.mocks.ts'
import { mockKnowledgeBaseAnswerQuery } from '#desktop/entities/knowledge-base/graphql/queries/knowledgeBaseAnswer.mocks.ts'
import {
mockLinkListQuery,
waitForLinkListQueryCalls,
} from '#desktop/entities/link/graphql/queries/linkList.mocks.ts'
const ANSWER_PATH = '/knowledge-base/locale/en-us/answer/1'
const ANSWER_TRANSLATION_ID = convertToGraphQLId('KnowledgeBase::Answer::Translation', 1)
describe('knowledge base answer sidebar', () => {
beforeEach(() => {
mockApplicationConfig({ kb_active_publicly: true })
mockKnowledgeBaseQuery({
knowledgeBase: {
id: convertToGraphQLId('KnowledgeBase', 1),
title: 'My Knowledge Base',
iconset: 'default',
isPubliclyAvailable: true,
isVisiblePublicly: true,
kbLocales: [
{
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
primary: true,
systemLocale: { id: '1', locale: 'en-us', name: 'English (United States)' },
},
],
currentLocale: {
id: convertToGraphQLId('KnowledgeBase::Locale', 1),
systemLocale: { id: '1', locale: 'en-us' },
},
},
})
})
it('renders the content sidebar', async () => {
const view = await visitView(ANSWER_PATH)
expect(await view.findByRole('complementary', { name: 'Content sidebar' })).toBeInTheDocument()
})
it('lists the tags of the answer', async () => {
mockKnowledgeBaseAnswerQuery({ knowledgeBaseAnswer: { tags: ['vip', 'billing'] } })
const view = await visitView(ANSWER_PATH)
expect(await view.findByRole('link', { name: 'vip' })).toHaveAttribute(
'href',
`/desktop/search/${encodeURI('tags:"vip"')}?entity=Ticket`,
)
expect(view.getByRole('link', { name: 'billing' })).toBeInTheDocument()
})
it('states that the answer has no tags yet', async () => {
mockKnowledgeBaseAnswerQuery({ knowledgeBaseAnswer: { tags: [] } })
const view = await visitView(ANSWER_PATH)
expect(await view.findByText('No tags added yet.')).toBeInTheDocument()
})
it('lists the tickets linked to the answer translation', async () => {
mockKnowledgeBaseAnswerQuery({
knowledgeBaseAnswer: { translationId: ANSWER_TRANSLATION_ID },
})
mockLinkListQuery({
linkList: [
{
item: {
__typename: 'Ticket',
id: convertToGraphQLId('Ticket', 1),
internalId: 1,
title: 'Printer on the second floor jams every other page',
},
type: EnumLinkType.Normal,
},
],
})
const view = await visitView(ANSWER_PATH)
expect(await view.findByText('Related tickets')).toBeInTheDocument()
expect(
await view.findByRole('link', { name: 'Printer on the second floor jams every other page' }),
).toHaveAttribute('href', '/desktop/tickets/1')
const calls = await waitForLinkListQueryCalls()
expect(calls.at(-1)?.variables).toEqual({
objectId: ANSWER_TRANSLATION_ID,
targetType: 'Ticket',
})
})
it('states that the answer has no linked tickets yet', async () => {
mockLinkListQuery({ linkList: [] })
const view = await visitView(ANSWER_PATH)
expect(await view.findByText('No links added yet.')).toBeInTheDocument()
})
})

View File

@@ -35,7 +35,8 @@ const category = (
) => ({
id,
title,
categoryIcon: 'folder',
categoryIcon: 'f115',
iconSet: 'FontAwesome',
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
answerCount: counts.answerCount ?? 0,
@@ -200,7 +201,7 @@ describe('knowledge base category navigation', () => {
.getAllByRole('navigation', { name: 'Knowledge base navigation' })
.at(-1)!
const rootBreadcrumbLink = await within(breadcrumb).findByRole('link', {
name: 'Knowledge Base',
name: 'Knowledge Base Home',
})
await view.events.click(rootBreadcrumbLink)

View File

@@ -1,34 +0,0 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonBadge from '#shared/components/CommonBadge/CommonBadge.vue'
import CommonIcon from '#shared/components/CommonIcon/CommonIcon.vue'
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import type { KnowledgeBaseAnswerCompact } from '../types.ts'
// Opening and reading an answer is handled by a separate story; for now the
// browse view only lists the answers with their visibility color-coding.
defineProps<KnowledgeBaseAnswerCompact>()
</script>
<template>
<li class="flex items-center gap-3 rounded-xl bg-blue-200 px-3 py-2.5 dark:bg-gray-500">
<KnowledgeBaseAnswerIcon :visibility="visibility" size="small" />
<CommonLabel size="medium" class="line-clamp-1! grow">
{{ title }}
</CommonLabel>
<CommonBadge
v-if="translationMissing"
v-tooltip="$t('No translation for this locale available')"
variant="warning"
size="xs"
rounded
class="flex items-center justify-center p-1!"
>
<CommonIcon name="translate" size="xs" decorative />
</CommonBadge>
</li>
</template>

View File

@@ -0,0 +1,34 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import { useAttachments } from '#shared/composables/useAttachments.ts'
import type { Attachment } from '#shared/entities/attachment/types.ts'
import CommonFileList from '#desktop/components/CommonFileList/CommonFileList.vue'
import { useFilePreviewViewer } from '#desktop/composables/useFilePreviewViewer.ts'
const props = defineProps<{
attachments?: Maybe<Attachment[]>
}>()
const { attachments } = useAttachments({
attachments: computed(() => props.attachments ?? []),
})
// Its own viewer, separate from the one the answer body runs for its inline images: the
// two are distinct sets, so they read as two galleries instead of one mixed one.
const { showPreview } = useFilePreviewViewer(attachments)
</script>
<template>
<section v-if="attachments.length">
<CommonFileList
:files="attachments"
:label="$t('Attachments')"
no-remove
@preview="showPreview"
/>
</section>
</template>

View File

@@ -0,0 +1,130 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useHtmlInlineImages } from '#shared/composables/useHtmlInlineImages.ts'
import { useHtmlLinks } from '#shared/composables/useHtmlLinks.ts'
import { setAutoDirectionOnChildElements } from '#shared/utils/dom.ts'
import { ensureImagesKeepAspectRatio } from '#shared/utils/helpers.ts'
import { useFilePreviewViewer, type ViewerFile } from '#desktop/composables/useFilePreviewViewer.ts'
const props = defineProps<{
content?: Maybe<{ bodyWithUrls?: Maybe<string> }>
}>()
const bodyElement = useTemplateRef('content-body')
// The body arrives sanitized and prepared from the server (`bodyWithUrls`): inline `cid:`
// images point at their attachment URL, links to other answers are resolved and video
// widget markers are expanded into embeds.
const body = computed(() => {
const bodyWithUrls = props.content?.bodyWithUrls
if (!bodyWithUrls) return ''
return ensureImagesKeepAspectRatio(bodyWithUrls)
})
const inlineImages = ref<ViewerFile[]>([])
const { showPreview } = useFilePreviewViewer(inlineImages)
const { setupLinksHandlers } = useHtmlLinks('/desktop')
const { populateInlineImages } = useHtmlInlineImages(inlineImages, (index) =>
showPreview('image', inlineImages.value[index]),
)
// `v-html` replaces the nodes, so the handlers must be attached again whenever the body
// changes — on locale switch, on previous/next navigation and on a content update refetch.
const setupBody = async () => {
await nextTick()
if (!bodyElement.value) return
setupLinksHandlers(bodyElement.value)
populateInlineImages(bodyElement.value)
setAutoDirectionOnChildElements(bodyElement.value)
}
watch(body, setupBody)
onMounted(setupBody)
</script>
<template>
<div v-if="body" class="Content w-full">
<section>
<!-- eslint-disable vue/no-v-html -->
<article ref="content-body" class="inner-article-body text-sm" v-html="body" />
</section>
</div>
</template>
<style scoped>
.inner-article-body {
word-break: normal;
overflow-wrap: anywhere;
/* A full-page answer reads with more air than a ticket article bubble. */
&:deep(p) {
margin-block: 1rem;
}
&:deep(img, svg) {
display: inline;
}
/* Video widgets are expanded server-side into this wrapper; keep them responsive 16:9. */
&:deep(.videoWrapper) {
position: relative;
height: 0;
padding-block: 25px 56.25%;
iframe {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
}
}
/*
Strip inline color styles in dark mode.
However, we need to keep the colors of the Zammad palette.
*/
[data-theme='dark'] &:deep(*[style*='color']):not(
[style*='color:rgb(102, 102, 102)'], [style*='color: rgb(102, 102, 102)'], /* neutral 1 */
[style*='color:rgb(153, 153, 153)'], [style*='color: rgb(153, 153, 153)'], /* neutral 2 */
[style*='color:rgb(204, 204, 204)'], [style*='color: rgb(204, 204, 204)'], /* neutral 3 */
[style*='color:rgb(239, 68, 68)'], [style*='color: rgb(239, 68, 68)'], /* red 1 */
[style*='color:rgb(205, 121, 45)'], [style*='color: rgb(205, 121, 45)'], /* orange 1 */
[style*='color:rgb(80, 140, 70)'], [style*='color: rgb(80, 140, 70)'], /* green 1 */
[style*='color:rgb(48, 100, 172)'], [style*='color: rgb(48, 100, 172)'], /* blue 1 */
[style*='color:rgb(107, 41, 132)'], [style*='color: rgb(107, 41, 132)'], /* purple 1 */
[style*='color:rgb(235, 61, 79)'], [style*='color: rgb(235, 61, 79)'], /* red 2 */
[style*='color:rgb(233, 159, 59)'], [style*='color: rgb(233, 159, 59)'], /* orange 2 */
[style*='color:rgb(95, 159, 84)'], [style*='color: rgb(95, 159, 84)'], /* green 2 */
[style*='color:rgb(70, 147, 231)'], [style*='color: rgb(70, 147, 231)'], /* blue 2 */
[style*='color:rgb(153, 62, 195)'], [style*='color: rgb(153, 62, 195)'], /* purple 2 */
[style*='color:rgb(237, 97, 118)'], [style*='color: rgb(237, 97, 118)'], /* red 3 */
[style*='color:rgb(243, 193, 79)'], [style*='color: rgb(243, 193, 79)'], /* orange 3 */
[style*='color:rgb(127, 187, 118)'], [style*='color: rgb(127, 187, 118)'], /* green 3 */
[style*='color:rgb(91, 174, 243)'], [style*='color: rgb(91, 174, 243)'], /* blue 3 */
[style*='color:rgb(179, 91, 223)'], [style*='color: rgb(179, 91, 223)'], /* purple 3 */
[style*='color:rgb(241, 152, 167)'], [style*='color: rgb(241, 152, 167)'], /* red 4 */
[style*='color:rgb(246, 211, 102)'], [style*='color: rgb(246, 211, 102)'], /* orange 4 */
[style*='color:rgb(170, 214, 164)'], [style*='color: rgb(170, 214, 164)'], /* green 4 */
[style*='color:rgb(122, 202, 247)'], [style*='color: rgb(122, 202, 247)'], /* blue 4 */
[style*='color:rgb(201, 135, 236)'], [style*='color: rgb(201, 135, 236)'] /* purple 4 */
) {
color: inherit !important;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
// Roughly the shape of an answer body: two paragraphs of a few lines each, with the
// last line of every paragraph running short.
const paragraphs = [
['w-full', 'w-full', 'w-2/3'],
['w-full', 'w-5/6', 'w-1/2'],
]
</script>
<template>
<div class="flex w-full flex-col gap-6">
<div v-for="(lines, index) in paragraphs" :key="index" class="flex flex-col gap-2.5">
<CommonSkeleton
v-for="(width, lineIndex) in lines"
:key="lineIndex"
class="h-3.5"
:class="width"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import type { Orientation } from '#desktop/components/CommonPopover/types.ts'
import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
import TicketPopoverWithTrigger from '#desktop/components/Ticket/TicketPopoverWithTrigger.vue'
import { useObjectLinks } from '#desktop/entities/link/composables/useObjectLinks.ts'
import KnowledgeBaseAnswerLinkedTicketsSkeleton from './KnowledgeBaseAnswerLinkedTicketsSkeleton.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
interface Props {
answer: KnowledgeBaseAnswerHeader
}
const props = defineProps<Props>()
// Links belong to the answer *translation* of the browsed locale, not to the
// answer: they differ per locale, and `linkList` only takes a translation ID.
// An answer without any translation has none to link to, hence the gate.
const translation = computed(() =>
props.answer.translationId ? { id: props.answer.translationId } : undefined,
)
const enabled = computed(() => Boolean(props.answer.translationId))
const { links, linkListIsLoading } = useObjectLinks(translation, 'Ticket', { enabled })
// The link item is a union and the query selects both of its branches; only the
// ticket one can appear for this target type.
const tickets = computed(() =>
links.value.flatMap((link) => (link.item.__typename === 'Ticket' ? [link.item] : [])),
)
const popoverConfig: { orientation: Orientation } = { orientation: 'left' }
</script>
<template>
<CommonSectionCollapse id="kb-related-tickets" :title="__('Related tickets')">
<CommonLoader class="w-full" :loading="linkListIsLoading">
<template #skeleton>
<KnowledgeBaseAnswerLinkedTicketsSkeleton />
</template>
<ul v-if="tickets.length" class="flex flex-col rounded-lg bg-blue-200 dark:bg-gray-700">
<li v-for="ticket in tickets" :key="ticket.id" class="flex items-center p-1">
<TicketPopoverWithTrigger
:popover-config="popoverConfig"
class="flex grow items-center rounded-md! px-1.5"
:ticket="ticket"
no-wrap
/>
</li>
</ul>
<CommonLabel v-else size="small">
{{ $t('No links added yet.') }}
</CommonLabel>
</CommonLoader>
</CommonSectionCollapse>
</template>

View File

@@ -0,0 +1,19 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
</script>
<template>
<div class="flex w-full flex-col rounded-lg bg-blue-200 p-1 dark:bg-gray-700">
<div class="flex items-center gap-2 p-2.5 px-1.5">
<CommonSkeleton alternative-background rounded class="size-5 shrink-0" />
<CommonSkeleton alternative-background class="h-4 w-32" />
</div>
<div class="flex items-center gap-2 p-2.5 px-1.5">
<CommonSkeleton alternative-background rounded class="size-5 shrink-0" />
<CommonSkeleton alternative-background class="h-4 w-40" />
</div>
</div>
</template>

View File

@@ -0,0 +1,100 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
import { i18n } from '#shared/i18n.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import CollapseButton from '#desktop/components/CollapseButton/CollapseButton.vue'
import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
import type { SidebarName } from '#desktop/components/layout/types.ts'
import { useSidebarDisplay } from '#desktop/components/layout/useSidebarDisplay.ts'
interface Props {
name: SidebarName
title: string
icon: string
titleClass?: string
iconClass?: string
entity?: ObjectLike
actions?: MenuItem[]
}
const props = withDefaults(defineProps<Props>(), {
titleClass: '',
iconClass: 'text-stone-200 dark:text-neutral-500',
})
const { isSidebarCollapsed, toggleSidebar } = useSidebarDisplay(props.name)
const { isTouchDevice } = useTouchDevice()
const translatedTitle = computed(() => i18n.t(props.title))
</script>
<template>
<div class="flex h-full justify-end">
<div v-show="!isSidebarCollapsed" class="flex grow flex-col overflow-hidden">
<div class="flex w-full gap-2 p-3">
<CommonLabel
tag="h2"
class="min-h-7 grow gap-1.5"
:class="titleClass"
size="large"
:prefix-icon="icon"
:icon-color="iconClass"
>
{{ translatedTitle }}
</CommonLabel>
<CommonActionMenu
v-if="actions?.length"
class="text-gray-100 dark:text-neutral-400"
no-single-action-mode
placement="arrowEnd"
:entity="entity"
:actions="actions"
/>
</div>
<div class="flex h-full flex-col gap-3 overflow-y-auto">
<slot />
</div>
</div>
<div
class="flex flex-col items-center gap-2.5 border-neutral-100 px-2.5 py-3 transition-[border] dark:border-gray-900"
:class="{ 'border-s': !isSidebarCollapsed }"
>
<CommonButton
v-tooltip="translatedTitle"
class="text-black! outline-1! outline-offset-1 outline-blue-800! dark:text-white!"
size="large"
variant="neutral"
:icon="icon"
:aria-label="translatedTitle"
@click="toggleSidebar(false)"
/>
<CollapseButton
class="mt-auto"
:class="{ 'lg:hidden': !isTouchDevice }"
owner-id="content-sidebar"
visible
no-padded
size="large"
variant="tertiary-gray"
inverse
:collapsed="isSidebarCollapsed"
:collapse-label="$t('Collapse sidebar')"
:expand-label="$t('Expand sidebar')"
@toggle-collapse="toggleSidebar"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
import { userDisplayName } from '#shared/entities/user/utils/getUserDisplayName.ts'
import CommonObjectAttribute from '#desktop/components/CommonObjectAttribute/CommonObjectAttribute.vue'
import CommonObjectAttributeContainer from '#desktop/components/CommonObjectAttribute/CommonObjectAttributeContainer.vue'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import KnowledgeBaseAnswerLinkedTickets from './KnowledgeBaseAnswerLinkedTickets.vue'
import KnowledgeBaseAnswerTags from './KnowledgeBaseAnswerTags.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
const props = defineProps<{ answer: KnowledgeBaseAnswerHeader }>()
const editedByLabel = computed(() => {
const editedBy = props.answer?.editedBy
return editedBy ? userDisplayName(editedBy) : undefined
})
</script>
<template>
<CommonObjectAttributeContainer class="p-3">
<CommonObjectAttribute :label="__('Visibility')">
{{ $t(visibilityMeta[answer.visibility].label) }}
</CommonObjectAttribute>
<CommonObjectAttribute v-if="answer.internalAt" :label="__('Internally published at')">
<CommonDateTime :date-time="answer.internalAt" type="relative" />
</CommonObjectAttribute>
<CommonObjectAttribute v-if="answer.publishedAt" :label="__('Published at')">
<CommonDateTime :date-time="answer.publishedAt" type="relative" />
</CommonObjectAttribute>
<CommonObjectAttribute v-if="answer.archivedAt" :label="__('Archived at')">
<CommonDateTime :date-time="answer.archivedAt" type="relative" />
</CommonObjectAttribute>
<CommonObjectAttribute v-if="answer.editedAt" :label="__('Last updated')">
<CommonDateTime :date-time="answer.editedAt" type="relative" />
</CommonObjectAttribute>
<CommonObjectAttribute v-if="editedByLabel" :label="__('Updated by')">
<span v-tooltip.supportive="editedByLabel" class="line-clamp-1">
{{ editedByLabel }}
</span>
</CommonObjectAttribute>
</CommonObjectAttributeContainer>
<KnowledgeBaseAnswerTags :answer="answer" class="p-3" />
<KnowledgeBaseAnswerLinkedTickets :answer="answer" class="p-3" />
</template>

View File

@@ -0,0 +1,46 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script lang="ts" setup>
import { computed } from 'vue'
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import CommonLink from '#shared/components/CommonLink/CommonLink.vue'
import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
import { tagSearchRoute } from '#desktop/entities/tags/utils/routeLocation.ts'
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
interface Props {
answer: KnowledgeBaseAnswerHeader
}
const props = defineProps<Props>()
const tags = computed(
() => props.answer.tags?.map((tag) => ({ label: tag, link: tagSearchRoute(tag) })) ?? [],
)
</script>
<template>
<CommonSectionCollapse id="knowledge-base-tags" :title="__('Tags')">
<template #default="{ headerId }">
<ul
v-if="tags.length"
:aria-labelledby="headerId"
class="flex w-full flex-col rounded-lg bg-blue-200 px-2.5 dark:bg-gray-700"
>
<li v-for="tag in tags" :key="tag.label" class="flex items-center gap-1.5 py-2.5">
<CommonLabel class="grow" prefix-icon="tag">
<CommonLink class="line-clamp-1" :link="tag.link" size="medium">
{{ tag.label }}
</CommonLink>
</CommonLabel>
</li>
</ul>
<CommonLabel v-else size="small">
{{ $t('No tags added yet.') }}
</CommonLabel>
</template>
</CommonSectionCollapse>
</template>

View File

@@ -0,0 +1,155 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import {
EnumKnowledgeBaseVisibility,
EnumLinkType,
EnumTicketStateColorCode,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import {
mockLinkListQuery,
waitForLinkListQueryCalls,
} from '#desktop/entities/link/graphql/queries/linkList.mocks.ts'
import KnowledgeBaseAnswerLinkedTickets from '../KnowledgeBaseAnswerLinkedTickets.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../../types.ts'
const translationId = convertToGraphQLId('KnowledgeBase::Answer::Translation', 1)
const answer = {
__typename: 'KnowledgeBaseAnswer',
id: convertToGraphQLId('KnowledgeBase::Answer', 1),
title: 'Some Answer',
visibility: EnumKnowledgeBaseVisibility.Published,
translationId,
translationMissing: false,
internalAt: null,
publishedAt: null,
archivedAt: null,
editedAt: null,
editedBy: null,
navigation: null,
content: {
__typename: 'KnowledgeBaseAnswerTranslationContent',
bodyWithUrls: 'Et non omnis. Iste rerum ut. Reiciendis officia cumque.',
id: convertToGraphQLId('KnowledgeBase::AnswerTranslationContent', 1),
},
category: {
__typename: 'KnowledgeBaseCategory',
id: convertToGraphQLId('KnowledgeBase::Category', 1),
breadcrumb: [],
},
tags: [],
attachments: [],
} as KnowledgeBaseAnswerHeader
const linkedTicket = (
internalId: number,
title: string,
stateColorCode: EnumTicketStateColorCode,
) => ({
item: {
__typename: 'Ticket' as const,
id: convertToGraphQLId('Ticket', internalId),
internalId,
title,
stateColorCode,
},
type: EnumLinkType.Normal,
})
const renderLinkedTickets = (props: { answer: KnowledgeBaseAnswerHeader } = { answer }) =>
renderComponent(KnowledgeBaseAnswerLinkedTickets, {
props,
store: true,
router: true,
})
describe('KnowledgeBaseAnswerLinkedTickets', () => {
beforeEach(() => {
mockPermissions(['ticket.agent'])
mockLinkListQuery({
linkList: [
linkedTicket(
1,
'Printer on the second floor jams every other page',
EnumTicketStateColorCode.Open,
),
linkedTicket(
2,
'VPN disconnects after roughly ten minutes',
EnumTicketStateColorCode.Pending,
),
],
})
})
it('labels the section as related tickets', async () => {
const view = renderLinkedTickets()
expect(await view.findByText('Related tickets')).toBeInTheDocument()
})
// The links belong to the translation of the browsed locale, not to the answer.
it('looks the links up by the answer translation', async () => {
renderLinkedTickets()
const calls = await waitForLinkListQueryCalls()
expect(calls.at(-1)?.variables).toEqual({ objectId: translationId, targetType: 'Ticket' })
})
it('renders a row per linked ticket, linking to it', async () => {
const view = renderLinkedTickets()
const links = await view.findAllByRole('link')
expect(links).toHaveLength(2)
expect(links[0]).toHaveAccessibleName('Printer on the second floor jams every other page')
expect(links[0]).toHaveAttribute('href', '/tickets/1')
expect(links[1]).toHaveAccessibleName('VPN disconnects after roughly ten minutes')
expect(links[1]).toHaveAttribute('href', '/tickets/2')
})
it('shows the state of every linked ticket', async () => {
const view = renderLinkedTickets()
await view.findAllByRole('link')
const states = view.getAllByRole('status')
expect(states[0]).toHaveAttribute('aria-roledescription', '(ticket status: open)')
expect(states[1]).toHaveAttribute('aria-roledescription', '(ticket status: pending)')
})
it('states that the answer has no links yet', async () => {
mockLinkListQuery({ linkList: [] })
const view = renderLinkedTickets()
expect(await view.findByText('No links added yet.')).toBeInTheDocument()
})
// An answer without any translation has nothing links could hang off, so the
// lookup never runs and the section stays empty.
it('lists no ticket for an answer without a translation', async () => {
const view = renderLinkedTickets({
answer: { ...answer, translationId: null } as KnowledgeBaseAnswerHeader,
})
expect(await view.findByText('No links added yet.')).toBeInTheDocument()
await waitFor(() => {
expect(view.queryAllByRole('link')).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,80 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import KnowledgeBaseAnswerTags from '../KnowledgeBaseAnswerTags.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../../types.ts'
const answer = (tags: string[] | null): KnowledgeBaseAnswerHeader =>
({
__typename: 'KnowledgeBaseAnswer',
id: convertToGraphQLId('KnowledgeBase::Answer', 1),
title: 'Some Answer',
visibility: EnumKnowledgeBaseVisibility.Published,
translationId: convertToGraphQLId('KnowledgeBase::Answer::Translation', 1),
translationMissing: false,
internalAt: null,
publishedAt: null,
archivedAt: null,
editedAt: null,
editedBy: null,
navigation: null,
content: {
__typename: 'KnowledgeBaseAnswerTranslationContent',
bodyWithUrls: 'Et non omnis. Iste rerum ut. Reiciendis officia cumque.',
id: convertToGraphQLId('KnowledgeBase::AnswerTranslationContent', 1),
},
category: {
__typename: 'KnowledgeBaseCategory',
id: convertToGraphQLId('KnowledgeBase::Category', 1),
breadcrumb: [],
},
tags,
attachments: [],
}) as KnowledgeBaseAnswerHeader
const renderTags = (tags: string[] | null) =>
renderComponent(KnowledgeBaseAnswerTags, {
props: { answer: answer(tags) },
store: true,
router: true,
})
describe('KnowledgeBaseAnswerTags', () => {
it('lists every tag of the answer', () => {
const view = renderTags(['vip', 'billing', 'second level'])
expect(view.getAllByRole('listitem')).toHaveLength(3)
expect(view.getByText('vip')).toBeInTheDocument()
expect(view.getByText('billing')).toBeInTheDocument()
expect(view.getByText('second level')).toBeInTheDocument()
})
it('states that no tags are assigned yet', () => {
const view = renderTags([])
expect(view.getByText('No tags added yet.')).toBeInTheDocument()
expect(view.queryByRole('list')).not.toBeInTheDocument()
})
// The field is nullable in the query, so an absent list must not differ from
// an empty one.
it('treats a missing tag list like an empty one', () => {
const view = renderTags(null)
expect(view.getByText('No tags added yet.')).toBeInTheDocument()
})
it.each([
['with tags', ['vip']],
['without tags', []],
])('keeps the attribute label %s', (_, tags) => {
const view = renderTags(tags as string[])
expect(view.getByText('Tags')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,52 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import CommonBadge from '#shared/components/CommonBadge/CommonBadge.vue'
import CommonIcon from '#shared/components/CommonIcon/CommonIcon.vue'
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { knowledgeBaseAnswerRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import { useKnowledgeBaseStore } from '../../../../entities/knowledge-base/stores/knowledgeBase.ts'
import type { KnowledgeBaseAnswerCompact } from '../../types.ts'
const props = defineProps<KnowledgeBaseAnswerCompact>()
const { activeLocale } = storeToRefs(useKnowledgeBaseStore())
// The answer route pins the locale; without it there is no valid target yet.
const link = computed(() =>
activeLocale.value ? knowledgeBaseAnswerRoute(activeLocale.value, props.id) : undefined,
)
</script>
<template>
<li>
<component
:is="link ? 'CommonLink' : 'div'"
:link="link"
:internal="link ? true : undefined"
class="flex w-full items-center gap-3 rounded-xl! bg-blue-200 px-3 py-2.5 hover:outline-1 hover:outline-blue-600 dark:bg-gray-500 hover:dark:outline-blue-900"
>
<KnowledgeBaseAnswerIcon :visibility="visibility" size="small" />
<CommonLabel size="medium" class="line-clamp-1! grow text-black! dark:text-white!">
{{ title }}
</CommonLabel>
<CommonBadge
v-if="translationMissing"
v-tooltip="$t('No translation for this locale available')"
variant="warning"
size="xs"
rounded
class="flex items-center justify-center p-1!"
>
<CommonIcon name="translate" size="xs" decorative />
</CommonBadge>
</component>
</li>
</template>

View File

@@ -8,11 +8,11 @@ import { useDebouncedLoading } from '#shared/composables/useDebouncedLoading.ts'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import { useKnowledgeBaseAnswers } from '../composables/useKnowledgeBaseAnswers.ts'
import { useKnowledgeBaseAnswers } from '../../composables/useKnowledgeBaseAnswers.ts'
import AnswerCard from './AnswerCard.vue'
import AnswerListSkeleton from './AnswerListSkeleton.vue'
import AnswerSkeleton from './AnswerSkeleton.vue'
import KnowledgeBaseAnswerCard from './KnowledgeBaseAnswerCard.vue'
import KnowledgeBaseAnswerCardSkeleton from './KnowledgeBaseAnswerCardSkeleton.vue'
import KnowledgeBaseAnswerListSkeleton from './KnowledgeBaseAnswerListSkeleton.vue'
const props = defineProps<{
// The open category; without one (the knowledge base root) there are no answers.
@@ -46,16 +46,16 @@ const { debouncedLoading } = useDebouncedLoading({
<template>
<CommonLoader :loading="loading">
<ol class="mt-4 flex flex-col gap-4">
<AnswerCard v-for="answer in answers" :key="answer.id" v-bind="answer" />
<KnowledgeBaseAnswerCard v-for="answer in answers" :key="answer.id" v-bind="answer" />
<!-- todo: show add answer button instead of div-->
<div v-if="categoryId && !answers.length"></div>
<template v-if="debouncedLoading">
<AnswerSkeleton v-for="i in 3" :key="i" :index="i" />
<KnowledgeBaseAnswerCardSkeleton v-for="i in 3" :key="i" :index="i" />
</template>
</ol>
<template #skeleton>
<AnswerListSkeleton :count="totalAnswerCount" />
<KnowledgeBaseAnswerListSkeleton :count="totalAnswerCount" />
</template>
</CommonLoader>
</template>

View File

@@ -1,7 +1,7 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import AnswerSkeleton from './AnswerSkeleton.vue'
import KnowledgeBaseAnswerCardSkeleton from './KnowledgeBaseAnswerCardSkeleton.vue'
interface Props {
count?: number
@@ -14,6 +14,6 @@ withDefaults(defineProps<Props>(), {
<template>
<ol class="mt-4 flex flex-col gap-4">
<AnswerSkeleton v-for="i in count" :key="i" :index="i" />
<KnowledgeBaseAnswerCardSkeleton v-for="i in count" :key="i" :index="i" />
</ol>
</template>

View File

@@ -16,17 +16,17 @@ import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
import { knowledgeBaseBrowseRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import KnowledgeBaseIconStatus from '#desktop/pages/knowledge-base/components/KnowledgeBaseIconStatus.vue'
import { useKnowledgeBaseAccess } from '../../../entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseStore } from '../../../entities/knowledge-base/stores/knowledgeBase.ts'
import { useKnowledgeBaseAccess } from '../../../../entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseStore } from '../../../../entities/knowledge-base/stores/knowledgeBase.ts'
import type { KnowledgeBaseCategoryCompact } from '../types.ts'
import type { KnowledgeBaseCategoryCompact } from '../../types.ts'
const props = withDefaults(defineProps<KnowledgeBaseCategoryCompact>(), {
subcategoryCount: 0,
answerCount: 0,
})
const { activeLocale } = storeToRefs(useKnowledgeBaseStore())
const { activeLocale, iconSet } = storeToRefs(useKnowledgeBaseStore())
const link = computed<Link | undefined>((currentLink) => {
// The category route pins the locale; without it there is no valid target
@@ -55,7 +55,7 @@ const actions: MenuItem[] = [
:is="link ? 'CommonLink' : 'div'"
:link="link"
:internal="link ? true : undefined"
class="group relative flex size-full flex-col rounded-xl! bg-blue-200 px-3 pt-6 pb-2.25 hover:bg-blue-600 active:bg-blue-800! dark:bg-gray-500 hover:dark:bg-blue-900"
class="relative flex size-full flex-col rounded-xl! bg-blue-200 px-3 pt-6 pb-2.25 hover:outline-1 hover:outline-blue-600 dark:bg-gray-500 hover:dark:outline-blue-900"
>
<CommonBadge
v-if="translationMissing"
@@ -63,20 +63,20 @@ const actions: MenuItem[] = [
variant="warning"
size="xs"
rounded
class="absolute inset-s-3 top-3 flex aspect-square items-center justify-center p-1! group-hover:bg-black group-hover:text-blue-600 group-active:bg-white! group-hover:dark:bg-white group-hover:dark:text-blue-900"
class="absolute inset-s-3 top-3 flex aspect-square items-center justify-center p-1!"
>
<CommonIcon name="translate" size="xs" decorative />
</CommonBadge>
<div class="flex min-h-26.75 flex-col items-center gap-3">
<!-- set default icon for now -->
<!-- :name="categoryIcon" -->
<KnowledgeBaseIconStatus name="folder" size="medium" :status="visibility" />
<KnowledgeBaseIconStatus
:name="categoryIcon"
:set="iconSet"
size="medium"
:status="visibility"
/>
<div class="flex min-h-11 w-full items-center justify-center">
<CommonLabel
size="medium"
class="line-clamp-2! text-center text-black! group-active:text-white! dark:text-white!"
>
<CommonLabel size="medium" class="line-clamp-2! text-center text-black! dark:text-white!">
{{ title }}
</CommonLabel>
</div>
@@ -90,13 +90,9 @@ const actions: MenuItem[] = [
v-tooltip="$t('Category count: %s', subcategoryCount)"
class="flex items-center gap-1"
>
<CommonIcon
name="folder"
size="small"
class="text-stone-200 group-hover:text-black group-active:text-white dark:text-neutral-500 group-hover:dark:text-white dark:group-active:text-white"
/>
<CommonIcon name="folder" size="xs" class="text-stone-200 dark:text-neutral-500" />
<CommonBadge
class="cursor-pointer! px-1.5 py-0.5 text-center leading-snug font-bold group-hover:bg-black group-hover:text-blue-600 group-active:bg-white! group-hover:dark:bg-white group-hover:dark:text-blue-900"
class="cursor-pointer! px-1.5 py-0.5 text-center leading-snug font-bold"
size="xs"
rounded
>
@@ -106,11 +102,11 @@ const actions: MenuItem[] = [
<div v-tooltip="$t('Answer count: %s', answerCount)" class="flex items-center gap-1">
<CommonIcon
name="file-richtext"
size="small"
class="text-stone-200 group-hover:text-black group-active:text-white dark:text-neutral-500 group-hover:dark:text-white dark:group-active:text-white"
size="xs"
class="text-stone-200 dark:text-neutral-500"
/>
<CommonBadge
class="cursor-pointer! px-1.5 py-0.5 text-center leading-snug font-bold group-hover:bg-black group-hover:text-blue-600 group-active:bg-white! group-hover:dark:bg-white group-hover:dark:text-blue-900"
class="cursor-pointer! px-1.5 py-0.5 text-center leading-snug font-bold"
size="xs"
rounded
>

View File

@@ -0,0 +1,91 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import '#tests/graphql/builders/mocks.ts'
import { flushPromises } from '@vue/test-utils'
import renderComponent, { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import KnowledgeBaseAnswerCard from '../KnowledgeBaseAnswerCard.vue'
const routerRoutes = [
{ name: 'Dashboard', path: '/', component: { template: '<div />' } },
{
name: 'KnowledgeBaseCategory',
path: '/knowledge-base/locale/:localeCode/category/:categoryInternalId(\\d+)',
component: { template: '<div />' },
},
{
name: 'KnowledgeBaseAnswer',
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId(\\d+)',
component: { template: '<div />' },
},
]
// The card takes the locale from the browsed URL; the test router only exists
// after the first render, so the route is set afterwards.
const renderCard = async (props = {}, path = '/knowledge-base/locale/en-us/category/1') => {
const wrapper = renderComponent(KnowledgeBaseAnswerCard, {
router: true,
routerRoutes,
props: {
id: convertToGraphQLId('KnowledgeBase::Answer', 1),
title: 'Getting Started',
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
position: 0,
...props,
},
})
await getTestRouter().push(path)
await flushPromises()
return wrapper
}
describe('KnowledgeBaseAnswerCard', () => {
it('renders the answer title', async () => {
const wrapper = await renderCard({ title: 'Reset your password' })
expect(wrapper.getByText('Reset your password')).toBeInTheDocument()
})
it('links to the answer of the browsed locale', async () => {
const wrapper = await renderCard({ id: convertToGraphQLId('KnowledgeBase::Answer', 42) })
expect(wrapper.getByRole('link')).toHaveAttribute(
'href',
'/desktop/knowledge-base/locale/en-us/answer/42',
)
})
it('renders no link without a browsed locale', async () => {
const wrapper = await renderCard({}, '/')
expect(wrapper.queryByRole('link')).not.toBeInTheDocument()
expect(wrapper.getByText('Getting Started')).toBeInTheDocument()
})
it('passes the answer visibility to the status icon', async () => {
const wrapper = await renderCard({ visibility: EnumKnowledgeBaseVisibility.Internal })
expect(wrapper.getByIconName('kb-internal')).toBeInTheDocument()
})
it('warns when the answer has no translation in the browsed locale', async () => {
const wrapper = await renderCard({ translationMissing: true })
expect(wrapper.getByIconName('translate')).toBeInTheDocument()
expect(wrapper.getByLabelText('No translation for this locale available')).toBeInTheDocument()
})
it('shows no translation warning when a translation exists', async () => {
const wrapper = await renderCard({ translationMissing: false })
expect(wrapper.queryByIconName('translate')).not.toBeInTheDocument()
})
})

View File

@@ -5,17 +5,18 @@ import { renderComponent } from '#tests/support/components/index.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import CategoryCard from '../CategoryCard.vue'
import KnowledgeBaseCategoryCard from '../KnowledgeBaseCategoryCard.vue'
import '#tests/graphql/builders/mocks.ts'
const renderCard = (props = {}) =>
renderComponent(CategoryCard, {
renderComponent(KnowledgeBaseCategoryCard, {
router: true,
props: {
id: convertToGraphQLId('KnowledgeBase::Category', 1),
title: 'Getting Started',
visibility: EnumKnowledgeBaseVisibility.Published,
categoryIcon: 'headset',
categoryIcon: 'f004',
iconSet: 'FontAwesome',
subcategoryCount: 0,
answerCount: 0,
translationMissing: false,
@@ -24,7 +25,7 @@ const renderCard = (props = {}) =>
},
})
describe('CategoryCard', () => {
describe('KnowledgeBaseCategoryCard', () => {
it('shows the subcategory and answer count badges', () => {
const wrapper = renderCard({ subcategoryCount: 3, answerCount: 7 })
@@ -38,11 +39,14 @@ describe('CategoryCard', () => {
expect(wrapper.getAllByText('0').length).toBe(2)
})
it('renders the category icon and falls back to folder', () => {
// breadcrumb + category card = 2 icons
expect(renderCard({ categoryIcon: 'folder' }).getAllByIconName('folder')).toHaveLength(2)
// expect(renderCard({ categoryIcon: 'headset' }).getByLabelText('headset')).toBeInTheDocument()
// expect(renderCard({ categoryIcon: '' }).getByLabelText('folder')).toBeInTheDocument()
it('renders the category icon', () => {
const wrapper = renderCard({ categoryIcon: 'f115', iconSet: 'FontAwesome' })
const svgElement = wrapper.container.querySelector('svg')!
expect(svgElement.querySelector('use')?.getAttribute('href')).toContain(
'FontAwesome.svg#icon-f115',
)
})
it('passes the category visibility to the status icon', () => {

View File

@@ -0,0 +1,34 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
import { usePrivateIcon } from '#shared/components/CommonIcon/usePrivateIcon.ts'
import type { KnowledgeBaseIconSet } from '../types.ts'
export interface Props {
name: string
set: KnowledgeBaseIconSet
size?: Sizes
fixedSize?: { width: number; height: number }
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
})
const { iconClass, finalSize } = usePrivateIcon(props)
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon fill-current"
:class="iconClass"
:width="finalSize.width"
:height="finalSize.height"
aria-hidden="true"
>
<use :href="`/assets/icon-fonts/${props.set}.svg#icon-${props.name}`" />
</svg>
</template>

View File

@@ -3,14 +3,18 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import CommonIcon from '#shared/components/CommonIcon/CommonIcon.vue'
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { useKnowledgeBaseVisibility } from '../composables/useKnowledgeBaseVisibility.ts'
import KnowledgeBaseCategoryIcon from './KnowledgeBaseCategoryIcon.vue'
import type { KnowledgeBaseIconSet } from '../types.ts'
interface Props {
name: string
set: KnowledgeBaseIconSet
size?: Sizes
status?: EnumKnowledgeBaseVisibility
}
@@ -33,26 +37,29 @@ const tooltipText = computed(() => {
return undefined
}
})
const metaIconSize = computed(() => {
switch (props.size) {
case 'xs':
return { width: 6, height: 6 }
default:
return { width: 12, height: 12 }
}
})
</script>
<template>
<div v-tooltip="$t(tooltipText)" class="relative h-fit">
<CommonIcon
:name="name"
:size="size"
:class="currentMetaClass"
class="group-hover:text-black! group-active:text-white! group-hover:dark:text-white!"
/>
<KnowledgeBaseCategoryIcon :name="name" :set="set" :size="size" :class="currentMetaClass" />
<div
v-if="currentMetaIcon"
class="absolute inset-e-0 bottom-0 flex translate-y-1 items-center justify-center rounded-full bg-blue-200 p-0.5 group-hover:bg-blue-600 group-active:bg-blue-800! ltr:translate-x-2 rtl:-translate-x-2 dark:bg-gray-500 group-hover:dark:bg-blue-900"
class="absolute inset-e-0 bottom-0 flex translate-y-1 items-center justify-center rounded-full bg-blue-200 p-0.5 ltr:translate-x-2 rtl:-translate-x-2 dark:bg-gray-500"
>
<CommonIcon
:name="currentMetaIcon"
decorative
size="xs"
:fixed-size="metaIconSize"
:class="currentMetaClass"
class="group-hover:text-black! group-active:text-white! group-hover:dark:text-white!"
/>
</div>
</div>

View File

@@ -1,348 +0,0 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { isEqual } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, toRef, useTemplateRef, type ComponentPublicInstance, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import CommonAlert from '#shared/components/CommonAlert/CommonAlert.vue'
import type { BreadcrumbItem } from '#desktop/components/CommonBreadcrumb/types.ts'
import type { DropdownItem } from '#desktop/components/CommonDropdown/types.ts'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
import { usePage } from '#desktop/composables/usePage.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 { useKnowledgeBaseAccess } from '../../../entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseStore } from '../../../entities/knowledge-base/stores/knowledgeBase.ts'
import { knowledgeBasePreviewUrl } from '../composables/useKnowledgeBasePreviewUrl.ts'
import { useKnowledgeBaseVisibility } from '../composables/useKnowledgeBaseVisibility.ts'
import type { CategoryBreadcrumb } from '../types.ts'
import type { TopBarHeaderProps } from './KnowledgeBaseTopBarHeader/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
// 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 headerWithDetailsElement = useTemplateRef<ComponentPublicInstance>('header-full')
const headerWithHiddenDetailsElement = useTemplateRef<ComponentPublicInstance>('header-compact')
const { height: headerWithDetailsHeight } = useElementSize(headerWithDetailsElement, undefined, {
box: 'border-box',
})
const { height: headerWithHiddenDetailsHeight } = useElementSize(
headerWithHiddenDetailsElement,
undefined,
{
box: 'border-box',
},
)
// When the translation alert docks below the header, the whole header+alert
// block is what slides and sticks — so its height (not the bare header's)
// drives the scroll thresholds, mirroring the ticket detail top bar.
const wrapperElement = useTemplateRef('wrapper')
const { height: wrapperHeight } = useElementSize(wrapperElement, undefined, {
box: 'border-box',
})
const { height: alertHeight } = useElementSize(useTemplateRef('alert'), undefined, {
box: 'border-box',
})
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))
// Only dock the alert once the category has resolved; while loading the header
// skeletons and there is no settled translation state to warn about yet.
const showTranslationAlert = computed(
() => Boolean(props.categoryTranslationMissing) && !loading.value,
)
const title = computed(() => props.categoryBreadcrumb?.at(-1)?.title ?? __('Knowledge Base'))
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 metaTitle = computed(() => {
const categoryTitle = props.categoryBreadcrumb?.at(-1)?.title
const kbTitle = knowledgeBase.value?.title ?? __('Knowledge Base')
return categoryTitle ? `${kbTitle} - ${categoryTitle}` : kbTitle
})
usePage({
metaTitle,
})
const { y } = useElementScroll(toRef(props, 'contentContainerElement') as Ref<HTMLDivElement>)
// Show the header earlier to always have it visible
const NEGATIVE_PADDING = -30
// The full block's height that the compact header must clear before docking —
// the header alone, or the header plus its alert when one is shown.
const fullBlockHeight = computed(() =>
showTranslationAlert.value
? wrapperHeight.value + alertHeight.value
: headerWithDetailsHeight.value,
)
const compactHeaderOffset = computed(() => y.value - (fullBlockHeight.value + NEGATIVE_PADDING))
const hasMeasuredHeaderHeights = computed(() => {
if (showTranslationAlert.value) return wrapperHeight.value > 0 && alertHeight.value > 0
return headerWithDetailsHeight.value > 0 && headerWithHiddenDetailsHeight.value > 0
})
// The compact header is stacked above the full header (higher z-index), so once it has fully
// slid into place it visually covers the full header. Interactivity/a11y exposure is switched
// over at the exact same point, so exactly one header is ever focusable/clickable/announced.
const isCompactHeaderVisible = computed(
() => hasMeasuredHeaderHeights.value && compactHeaderOffset.value > 0,
)
const absoluteContainerOffset = computed(
() => `${isCompactHeaderVisible.value ? 0 : compactHeaderOffset.value}px`,
)
const stickyContainerTop = computed(() => {
// With an alert the sticky block also carries the negative padding, so both
// headers slide in lockstep; without it the bare header keeps its behavior.
const threshold = showTranslationAlert.value
? fullBlockHeight.value + NEGATIVE_PADDING
: headerWithDetailsHeight.value
if (y.value < threshold) return `-${y.value}px`
return `-${threshold}px`
})
const alertBaseClasses = 'rounded-none md:grid-cols-none md:justify-center'
const alertWithBlurClasses = `${alertBaseClasses} opacity-95 backdrop-blur-2xs`
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const categoryBreadcrumb = props.categoryBreadcrumb ?? []
const items: BreadcrumbItem[] = [
{
label: __('Knowledge Base') as string,
icon: 'book',
iconOnly: true,
// Link back to the localized root only while browsing a category.
route:
categoryBreadcrumb.length && activeLocale.value
? knowledgeBaseBrowseRoute(activeLocale.value)
: undefined,
},
]
categoryBreadcrumb.forEach((category, index) => {
const isLast = index === categoryBreadcrumb.length - 1
const { currentMetaClass } = useKnowledgeBaseVisibility(category.visibility)
items.push({
label: category.title ?? '',
// :TODO: Category icon
// icon: 'category.categoryIcon,
icon: 'folder',
iconClass: currentMetaClass.value,
route:
isLast || !activeLocale.value
? undefined
: knowledgeBaseBrowseRoute(activeLocale.value, category.id),
})
})
return items
})
const localeItems = computed<DropdownItem[]>(
() =>
knowledgeBase.value?.kbLocales?.map((kbLocale) => ({
key: kbLocale.id,
label: kbLocale.systemLocale.name,
})) ?? [],
)
const activeKbLocale = computed(() =>
knowledgeBase.value?.kbLocales?.find(
(kbLocale) => kbLocale.systemLocale.locale === activeLocale.value,
),
)
const selectedLocaleItem = computed<DropdownItem | undefined>({
get: () =>
activeKbLocale.value
? { key: activeKbLocale.value.id, label: activeKbLocale.value.systemLocale.name }
: undefined,
set: (item) => {
const match = knowledgeBase.value?.kbLocales?.find((kbLocale) => kbLocale.id === item?.key)
if (!match) return
const localeCode = match.systemLocale.locale
const currentCategory = props.categoryBreadcrumb?.at(-1)
// The locale is part of the URL on both the root listing and category
// pages, so switch languages by navigating; the route change is what
// syncs the store, keeping URL and selector in lockstep. Stay in the open
// category when there is one, otherwise land on the localized root.
router.push(knowledgeBaseBrowseRoute(localeCode, currentCategory?.id))
},
})
const selectedLocaleCode = computed(
() => activeKbLocale.value?.systemLocale.locale.toUpperCase() ?? '',
)
const headerProps = computed<TopBarHeaderProps>((currentProps) => {
const updatedProps = {
title: title.value,
locales: localeItems.value,
breadcrumbs: breadcrumbItems.value,
localeCode: selectedLocaleCode.value,
previewUrl: previewUrl.value,
}
if (currentProps && isEqual(currentProps, updatedProps)) return currentProps
return updatedProps
})
</script>
<template>
<!-- Opened category untranslated in the browsed locale: dock a warning alert
below the header. Header and alert form one block that slides and sticks
together for both the full and the compact header, like the ticket detail
top bar's channel alert. -->
<template v-if="showTranslationAlert">
<div
class="absolute inset-x-0 top-0 z-30 print:hidden"
data-test-id="knowledge-base-header-compact"
:style="{
transform: `translateY(${absoluteContainerOffset})`,
}"
>
<TopBarHeaderCompact
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:inert="!isCompactHeaderVisible"
/>
<CommonAlert class="px-5.5!" :class="alertWithBlurClasses" variant="warning">
{{ $t('No translation for this locale available') }}
</CommonAlert>
</div>
<div
ref="wrapper"
class="sticky inset-x-0 top-0 z-10 w-full print:static"
data-test-id="knowledge-base-header-full"
:style="{
top: stickyContainerTop,
}"
>
<TopBarHeaderFull
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:inert="isCompactHeaderVisible"
/>
<div class="flex justify-center bg-yellow-50 px-5.5 dark:bg-yellow-900">
<CommonAlert
ref="alert"
class="max-w-[calc(var(--container-7xl)-2.750rem)] basis-full px-0! print:hidden"
:class="alertBaseClasses"
variant="warning"
>
{{ $t('No translation for this locale available') }}
</CommonAlert>
</div>
</div>
</template>
<template v-else>
<TopBarHeaderCompact
v-if="!loading"
ref="header-compact"
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
class="absolute inset-x-0 top-0 z-30 print:hidden"
data-test-id="user-detail-header-compact"
:inert="!isCompactHeaderVisible"
:style="{
transform: `translateY(${absoluteContainerOffset})`,
}"
/>
<CommonLoader class="w-full" :loading="loading">
<TopBarHeaderFull
ref="header-full"
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
class="sticky inset-x-0 top-0 z-10 w-full print:static"
data-test-id="user-detail-header-full"
:inert="isCompactHeaderVisible"
:style="{
top: stickyContainerTop,
}"
/>
<template #skeleton>
<TopBarHeaderFullSkeleton
class="sticky inset-x-0 top-0 z-10 w-full"
:style="{
top: stickyContainerTop,
}"
/>
</template>
</CommonLoader>
</template>
</template>

View File

@@ -0,0 +1,87 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import type { BadgeVariant } from '#shared/components/CommonBadge/types.ts'
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
import { userDisplayName } from '#shared/entities/user/utils/getUserDisplayName.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import type { KnowledgeBaseAnswerHeader } from '../../types.ts'
const props = defineProps<{ answer: KnowledgeBaseAnswerHeader }>()
const session = useSessionStore()
// The badge picks up the state color of the icon it carries, so the strip reads
// the same as the answer list.
const badgeVariants: Record<EnumKnowledgeBaseVisibility, BadgeVariant> = {
[EnumKnowledgeBaseVisibility.Draft]: 'tertiary',
[EnumKnowledgeBaseVisibility.Internal]: 'info',
[EnumKnowledgeBaseVisibility.Published]: 'success',
[EnumKnowledgeBaseVisibility.Archived]: 'tertiary',
}
const visibility = computed(() => props.answer.visibility)
// Deliberately a fixed value: the chip is rendered once per answer load, so it
// does not track elapsed time the way the CommonDateTime badges above do.
const editedLabel = computed(() => {
const { editedAt, editedBy } = props.answer
if (!editedAt) return undefined
const date = i18n.relativeDateTime(editedAt)
// Checked before the identity comparison: without an editor both sides would
// be `undefined` and the chip would claim the current user edited it.
if (!editedBy) return i18n.t('edited %s', date)
if (editedBy.id === session.user?.id) return i18n.t('edited %s by me', date)
return i18n.t('edited %s by %s', date, userDisplayName(editedBy))
})
</script>
<template>
<div class="flex max-w-full flex-wrap items-center gap-2.5 text-nowrap *:h-7">
<CommonBadge :variant="badgeVariants[visibility]" class="gap-1 uppercase">
<KnowledgeBaseAnswerIcon :visibility="visibility" size="tiny" />
{{ $t(visibilityMeta[visibility].label) }}
</CommonBadge>
<CommonBadge v-if="answer.internalAt" variant="tertiary" class="uppercase">
<CommonDateTime :date-time="answer.internalAt" type="relative" class="ms-1">
<template #prefix>
{{ $t('Internally published at') }}
</template>
</CommonDateTime>
</CommonBadge>
<CommonBadge v-if="answer.publishedAt" variant="tertiary" class="uppercase">
<CommonDateTime :date-time="answer.publishedAt" type="relative" class="ms-1">
<template #prefix>
{{ $t('Published at') }}
</template>
</CommonDateTime>
</CommonBadge>
<CommonBadge v-if="answer.archivedAt" variant="tertiary" class="uppercase">
<CommonDateTime :date-time="answer.archivedAt" type="relative" class="ms-1">
<template #prefix>
{{ $t('Archived at') }}
</template>
</CommonDateTime>
</CommonBadge>
<CommonBadge v-if="editedLabel" variant="tertiary" class="uppercase">
{{ editedLabel }}
</CommonBadge>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { isEqual } from 'lodash-es'
import { computed, toRef } from 'vue'
import { i18n } from '#shared/i18n.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import { knowledgeBaseAnswerRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import type { AnswerNavigationEntry, Navigation } from './types.ts'
export interface Props {
navigation: Navigation
localeCode: string
}
const props = defineProps<Props>()
const localeData = toRef(useLocaleStore(), 'localeData')
const previousAnswer = computed<AnswerNavigationEntry>((currentAnswer) => {
const answer = {
link: knowledgeBaseAnswerRoute(props.localeCode, props.navigation.previousAnswer.id),
label: props.navigation.previousAnswer.title
? i18n.t('Previous answer: %s', props.navigation.previousAnswer.title)
: i18n.t('Previous answer'),
}
if (currentAnswer && isEqual(answer, currentAnswer)) return currentAnswer
return answer
})
const nextAnswer = computed<AnswerNavigationEntry>((currentAnswer) => {
const answer = {
link: knowledgeBaseAnswerRoute(props.localeCode, props.navigation.nextAnswer.id),
label: props.navigation.nextAnswer.title
? i18n.t('Next answer: %s', props.navigation.nextAnswer.title)
: i18n.t('Next answer'),
}
if (currentAnswer && isEqual(answer, currentAnswer)) return currentAnswer
return answer
})
</script>
<template>
<nav v-if="navigation.totalCount > 1" :aria-label="$t('Answer navigation')">
<ol
class="ms-1 inline-flex items-center gap-0.5 rounded-md border border-neutral-100 bg-green-200 px-1 py-0.5 dark:border-gray-900 dark:bg-gray-600"
>
<li>
<CommonLink v-tooltip="previousAnswer.label" :link="previousAnswer.link">
<CommonIcon
size="xs"
:name="localeData?.dir === 'rtl' ? 'chevron-right' : 'chevron-left'"
/>
</CommonLink>
</li>
<li class="flex px-1 text-stone-200 dark:text-neutral-500">
<CommonLabel size="small">
{{ navigation.index }}
</CommonLabel>
<CommonLabel class="">/</CommonLabel>
<CommonLabel size="small">
{{ navigation.totalCount }}
</CommonLabel>
</li>
<li>
<CommonLink v-tooltip="nextAnswer.label" :link="nextAnswer.link">
<CommonIcon
size="xs"
:name="localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'"
/>
</CommonLink>
</li>
</ol>
</nav>
</template>

View File

@@ -0,0 +1,151 @@
<!-- 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 { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { usePage } from '#desktop/composables/usePage.ts'
import { knowledgeBaseAnswerRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import KnowledgeBaseAnswerHeaderDetails from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue'
import KnowledgeBaseAnswerStepper from '#desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerStepper.vue'
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 { useKnowledgeBaseAccess } from '../../../../entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseStore } from '../../../../entities/knowledge-base/stores/knowledgeBase.ts'
import { knowledgeBasePreviewUrl } from '../../composables/useKnowledgeBasePreviewUrl.ts'
import { knowledgeBaseBreadcrumbItems } from '../../utils/knowledgeBaseBreadcrumbItems.ts'
import { useKnowledgeBaseHeaderLocales } from './useKnowledgeBaseHeaderLocales.ts'
import type { TopBarHeaderProps } from './types.ts'
import type { KnowledgeBaseAnswerHeader } from '../../types.ts'
type Props = {
contentContainerElement: HTMLElement | null
answer?: KnowledgeBaseAnswerHeader
// The page's answer-loading state, so the header can skeleton in lockstep.
loading?: boolean
}
const props = defineProps<Props>()
const router = useRouter()
const { activeLocale, knowledgeBase, loading: baseLoading } = storeToRefs(useKnowledgeBaseStore())
const loading = computed(() => baseLoading.value || Boolean(props.loading))
const { canEdit, canRead } = useKnowledgeBaseAccess()
// The "view public knowledge base" button deep-links to the opened answer. It is
// only for internal users (reader/editor) — never public visitors — and is
// offered when the answer is published, or the user is an editor (who can
// preview unpublished content); undefined otherwise hides the button.
const previewUrl = computed(() => {
const locale = activeLocale.value
const { answer } = props
if (!locale || !answer || !canRead.value) return undefined
if (answer.visibility !== EnumKnowledgeBaseVisibility.Published && !canEdit.value)
return undefined
return knowledgeBasePreviewUrl('KnowledgeBaseAnswer', answer.id, locale)
})
const metaTitle = computed(() => {
const kbTitle = knowledgeBase.value?.title ?? __('Knowledge Base')
return props.answer?.title ? `${kbTitle} - ${props.answer.title}` : kbTitle
})
usePage({
metaTitle,
})
const breadcrumbItems = computed(() =>
knowledgeBaseBreadcrumbItems({
localeCode: activeLocale.value,
categoryBreadcrumb: props.answer?.category?.breadcrumb,
trailingItem: props.answer
? { label: props.answer.title ?? '', noOptionLabelTranslation: true }
: undefined,
}),
)
const { localeItems, selectedLocaleItem, selectedLocaleCode } = useKnowledgeBaseHeaderLocales(
// The answer is the same record in every locale, so switching languages stays
// on it instead of falling back to the category listing.
(localeCode) => {
if (!props.answer) return
router.push(knowledgeBaseAnswerRoute(localeCode, props.answer.id))
},
)
const headerProps = computed<TopBarHeaderProps>((currentProps) => {
const updatedProps = {
title: props.answer?.title,
locales: localeItems.value,
breadcrumbs: breadcrumbItems.value,
localeCode: selectedLocaleCode.value,
previewUrl: previewUrl.value,
}
if (currentProps && isEqual(currentProps, updatedProps)) return currentProps
return updatedProps
})
</script>
<template>
<TopBarHeaderShell
:content-container-element="contentContainerElement"
:loading="loading"
:alert-message="
answer?.translationMissing ? $t('No translation for this locale available') : undefined
"
content-width="reading"
>
<template #compact="{ inert }">
<TopBarHeaderCompact
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:copy-label="__('Copy answer title')"
:inert="inert"
>
<template v-if="answer?.navigation && activeLocale" #stepper>
<KnowledgeBaseAnswerStepper :navigation="answer.navigation" :locale-code="activeLocale" />
</template>
</TopBarHeaderCompact>
</template>
<template #full="{ inert }">
<TopBarHeaderFull
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:copy-label="__('Copy answer title')"
:inert="inert"
content-width="reading"
>
<template v-if="answer?.navigation && activeLocale" #stepper>
<KnowledgeBaseAnswerStepper :navigation="answer.navigation" :locale-code="activeLocale" />
</template>
<template v-if="answer" #details>
<KnowledgeBaseAnswerHeaderDetails :answer="answer" />
</template>
</TopBarHeaderFull>
</template>
<template #skeleton>
<TopBarHeaderFullSkeleton with-details content-width="reading" />
</template>
</TopBarHeaderShell>
</template>

View File

@@ -0,0 +1,161 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import { i18n } from '#shared/i18n.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import KnowledgeBaseIconStatus from '../KnowledgeBaseIconStatus.vue'
import type { KnowledgeBaseBreadcrumbItem } from '../../types.ts'
const props = withDefaults(
defineProps<{
items: KnowledgeBaseBreadcrumbItem[]
emphasizeLastItem?: boolean
size?: 'small' | 'large'
label?: string
}>(),
{
size: 'large',
label: __('Breadcrumb navigation'),
},
)
const locale = useLocaleStore()
const lastItemClasses = computed(() => {
return props.emphasizeLastItem ? ['last:dark:text-white last:text-black'] : []
})
const sizeClasses = computed(() => {
if (props.size === 'small') return ['text-xs']
return ['text-base'] // default -> 'large'
})
const getItemLabel = (item: KnowledgeBaseBreadcrumbItem) =>
item.noOptionLabelTranslation ? (item.label as string) : i18n.t(item.label as string)
const displayItems = computed(() =>
props.items.map((item) => Object.assign({}, item, { displayLabel: getItemLabel(item) })),
)
</script>
<template>
<nav :class="sizeClasses" :aria-label="$t(label)" class="max-w-full">
<ol class="flex">
<li
v-for="(item, idx) in displayItems"
:key="item.label as string"
class="flex items-center"
:class="[lastItemClasses, { 'print:hidden': idx === 0 }]"
>
<KnowledgeBaseIconStatus
v-if="!item.route && item.icon && item.iconSet"
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="xs"
class="me-2.25 shrink-0"
:class="item.iconClass"
/>
<CommonIcon
v-else-if="!item.route && item.icon"
:name="item.icon"
size="xs"
class="me-1 shrink-0"
:class="item.iconClass"
/>
<CommonLink
v-if="item.route && item.iconOnly"
v-tooltip="item.displayLabel"
class="inline-flex items-center text-gray-100 focus-visible-app-default hover:text-black dark:text-neutral-400 hover:dark:text-white"
:link="item.route"
internal
>
<KnowledgeBaseIconStatus
v-if="item.icon && item.iconSet"
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="xs"
class="me-1.25 shrink-0"
:class="item.iconClass"
/>
<CommonIcon
v-else-if="item.icon"
:name="item.icon"
size="xs"
class="shrink-0"
:class="item.iconClass"
/>
</CommonLink>
<CommonLink
v-else-if="item.route"
v-tooltip.supportive="item.displayLabel"
class="inline-flex items-center gap-1 focus-visible-app-default"
:link="item.route"
internal
>
<KnowledgeBaseIconStatus
v-if="item.icon && item.iconSet"
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="xs"
class="me-1.25 shrink-0"
:class="item.iconClass"
/>
<CommonIcon
v-else-if="item.icon"
:name="item.icon"
size="xs"
class="shrink-0"
:class="item.iconClass"
/>
<CommonLabel class="line-clamp-1! hover:text-black hover:dark:text-white" :size="size">
{{ item.displayLabel }}
</CommonLabel>
</CommonLink>
<component
:is="displayItems.at(-1) === item ? 'h1' : 'span'"
v-else
v-tooltip.supportive="item.displayLabel"
class="line-clamp-1"
:class="{
'text-black dark:text-white': item.isActive,
'break-all': displayItems.at(-1) === item,
}"
aria-current="page"
>
{{ item.displayLabel }}
</component>
<CommonBadge
v-if="item.count !== undefined"
class="leading-snug font-bold ltr:ml-1.5 rtl:mr-1.5"
size="xs"
rounded
>
{{ item.count }}
</CommonBadge>
<CommonIcon
v-if="idx !== displayItems.length - 1"
:name="locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'"
size="xs"
class="mx-1 inline-flex shrink-0 text-stone-200 dark:text-neutral-500"
/>
<!-- Add a slot at the end of the last item. -->
<slot v-if="idx === displayItems.length - 1" name="trailing" />
</li>
</ol>
</nav>
</template>

View File

@@ -0,0 +1,148 @@
<!-- 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 { usePage } from '#desktop/composables/usePage.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 { useKnowledgeBaseAccess } from '../../../../entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import { useKnowledgeBaseStore } from '../../../../entities/knowledge-base/stores/knowledgeBase.ts'
import { knowledgeBasePreviewUrl } from '../../composables/useKnowledgeBasePreviewUrl.ts'
import { knowledgeBaseBreadcrumbItems } from '../../utils/knowledgeBaseBreadcrumbItems.ts'
import { useKnowledgeBaseHeaderLocales } from './useKnowledgeBaseHeaderLocales.ts'
import type { TopBarHeaderProps } from './types.ts'
import type { CategoryBreadcrumb } 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
// 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(
() =>
props.categoryBreadcrumb?.at(-1)?.title ?? knowledgeBase.value?.title ?? __('Knowledge Base'),
)
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 metaTitle = computed(() => {
const categoryTitle = props.categoryBreadcrumb?.at(-1)?.title
const kbTitle = knowledgeBase.value?.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)),
)
const headerProps = computed<TopBarHeaderProps>((currentProps) => {
const updatedProps = {
title: title.value,
locales: localeItems.value,
breadcrumbs: breadcrumbItems.value,
localeCode: selectedLocaleCode.value,
previewUrl: previewUrl.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 for this locale available') : undefined
"
>
<template #compact="{ inert }">
<TopBarHeaderCompact
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:inert="inert"
/>
</template>
<template #full="{ inert }">
<TopBarHeaderFull
v-model:selected-locale="selectedLocaleItem"
v-bind="headerProps"
:inert="inert"
/>
</template>
<template #skeleton>
<TopBarHeaderFullSkeleton />
</template>
</TopBarHeaderShell>
</template>

View File

@@ -1,104 +1,25 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { toRef } from 'vue'
import CommonBreadcrumb from '#desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonDropdown from '#desktop/components/CommonDropdown/CommonDropdown.vue'
import type { DropdownItem } from '#desktop/components/CommonDropdown/types.ts'
import { useTopBarHeader } from './useTopBarHeader.ts'
import TopBarHeaderRow from './TopBarHeaderRow.vue'
import type { TopBarHeaderProps } from './types.ts'
const props = defineProps<TopBarHeaderProps>()
defineProps<TopBarHeaderProps & { copyLabel?: string }>()
const selectedLocale = defineModel<DropdownItem>('selectedLocale')
const { copyKnowledgeBaseNameToClipboard } = useTopBarHeader(toRef(props))
</script>
<template>
<header
class="flex w-full items-center gap-x-2 border-b border-neutral-100 bg-neutral-50/80 px-5.5 py-3 backdrop-blur-2xs dark:border-gray-900 dark:bg-gray-500/80"
>
<CommonBreadcrumb
class="flex h-6 grow"
:items="breadcrumbs"
size="small"
emphasize-last-item
:label="__('Knowledge base navigation')"
>
<template #trailing>
<CommonButton
v-tooltip="$t('Copy knowledge base name')"
variant="secondary"
icon="files"
size="small"
class="ms-1"
@click="copyKnowledgeBaseNameToClipboard"
/>
<TopBarHeaderRow v-bind="$props" v-model:selected-locale="selectedLocale" variant="compact">
<template #stepper>
<slot name="stepper" />
</template>
</CommonBreadcrumb>
<div
role="toolbar"
:aria-label="$t('Knowledge base controls')"
class="flex items-center gap-2.5"
>
<CommonLink
v-if="previewUrl"
v-tooltip="$t('View public knowledge base')"
:link="previewUrl"
external
open-in-new-tab
class="rounded-lg! bg-green-200 p-1 outline outline-offset-0! outline-neutral-100 hover:bg-green-200 hover:outline-blue-600 dark:bg-gray-600 dark:outline-gray-900 dark:hover:bg-gray-600 dark:hover:outline-blue-900"
size="small"
>
<CommonIcon size="tiny" name="box-arrow-in-up-right" />
</CommonLink>
<CommonDropdown v-model="selectedLocale" :items="locales" orientation="bottom">
<template #trigger="{ toggle, isOpen }">
<CommonButton
v-tooltip="$t('Change language')"
variant="none"
size="small"
class="rounded-lg bg-green-200! p-1! outline! outline-offset-0! outline-neutral-100 hover:bg-green-200 dark:bg-gray-600! dark:outline-gray-900 dark:hover:bg-gray-600"
:class="{ 'outline-blue-800!': isOpen }"
@click="toggle"
>
{{ localeCode }}
<template #label>
<span class="truncate">
{{ localeCode }}
</span>
<CommonIcon
size="xs"
decorative
:class="{
'text-white dark:text-white': isOpen,
'group-hover:text-black dark:group-hover:text-white': !isOpen,
}"
:name="isOpen ? 'chevron-up' : 'chevron-down'"
/>
</template>
</CommonButton>
</template>
</CommonDropdown>
<!-- :TODO -->
<!-- <CommonActionMenu
button-size="medium"
role="presentation"
class="flex! h-full items-center"
:custom-menu-button-label="$t('Additional actions')"
no-single-action-mode
placement="end"
hide-arrow
/> -->
</div>
</TopBarHeaderRow>
</header>
</template>

View File

@@ -1,112 +1,57 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { toRef } from 'vue'
import { computed } from 'vue'
import CommonBreadcrumb from '#desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonDropdown from '#desktop/components/CommonDropdown/CommonDropdown.vue'
import type { DropdownItem } from '#desktop/components/CommonDropdown/types.ts'
import { useTopBarHeader } from './useTopBarHeader.ts'
import {
HEADER_CONTENT_OUTER_CLASSES,
HEADER_CONTENT_WIDTH_CLASSES,
HEADER_ROWS_CLASS,
HEADER_ROWS_WITH_DETAILS_CLASS,
} from './headerClasses.ts'
import TopBarHeaderRow from './TopBarHeaderRow.vue'
import { type HeaderContentWidth, type TopBarHeaderProps } from './types.ts'
import type { TopBarHeaderProps } from './types.ts'
const props = withDefaults(
defineProps<TopBarHeaderProps & { copyLabel?: string; contentWidth?: HeaderContentWidth }>(),
{
contentWidth: 'wide',
},
)
const props = defineProps<TopBarHeaderProps>()
const contentWidthClass = computed(() => HEADER_CONTENT_WIDTH_CLASSES[props.contentWidth])
const contentOuterClass = computed(() => HEADER_CONTENT_OUTER_CLASSES[props.contentWidth])
const selectedLocale = defineModel<DropdownItem>('selectedLocale')
const { copyKnowledgeBaseNameToClipboard } = useTopBarHeader(toRef(props))
</script>
<template>
<header
class="grid w-full grid-cols-[1fr_min-content] grid-rows-[1.5rem_auto] gap-x-2 gap-y-2.5 border-b border-neutral-100 bg-neutral-50/80 px-5.5 py-3 backdrop-blur-2xs dark:border-gray-900 dark:bg-gray-500/80"
class="grid w-full grid-cols-[1fr_min-content] gap-x-2 gap-y-2.5 border-b border-neutral-100 bg-neutral-50/80 px-5.5 py-3 backdrop-blur-2xs dark:border-gray-900 dark:bg-gray-500/80"
:class="$slots.details ? HEADER_ROWS_WITH_DETAILS_CLASS : HEADER_ROWS_CLASS"
>
<CommonBreadcrumb
class="flex"
:items="breadcrumbs"
size="small"
emphasize-last-item
:label="__('Knowledge base navigation')"
>
<template #trailing>
<CommonButton
v-tooltip="$t('Copy knowledge base name')"
variant="secondary"
icon="files"
size="small"
class="ms-1"
@click="copyKnowledgeBaseNameToClipboard"
/>
<TopBarHeaderRow v-bind="$props" v-model:selected-locale="selectedLocale" variant="full">
<template #stepper>
<slot name="stepper" />
</template>
</CommonBreadcrumb>
</TopBarHeaderRow>
<div
role="toolbar"
:aria-label="$t('Knowledge base controls')"
class="flex items-center gap-2.5"
>
<CommonLink
v-if="previewUrl"
v-tooltip="$t('View public knowledge base')"
:link="previewUrl"
external
open-in-new-tab
class="rounded-lg! bg-green-200 p-1 outline outline-offset-0! outline-neutral-100 hover:bg-green-200 hover:outline-blue-600 dark:bg-gray-600 dark:outline-gray-900 dark:hover:bg-gray-600 dark:hover:outline-blue-900"
size="small"
<div class="col-span-2 flex items-center" :class="contentOuterClass">
<CommonLabel
class="mx-auto w-full text-xl font-medium text-black dark:text-white"
:class="contentWidthClass"
tag="span"
>
<CommonIcon size="tiny" name="box-arrow-in-up-right" />
</CommonLink>
<CommonDropdown v-model="selectedLocale" :items="locales" orientation="bottom">
<template #trigger="{ toggle, isOpen }">
<CommonButton
v-tooltip="$t('Change language')"
variant="none"
size="small"
class="rounded-lg bg-green-200! p-1! outline! outline-offset-0! outline-neutral-100 hover:bg-green-200 dark:bg-gray-600! dark:outline-gray-900 dark:hover:bg-gray-600"
:class="{ 'outline-blue-800!': isOpen }"
@click="toggle"
>
{{ localeCode }}
<template #label>
<span class="truncate">
{{ localeCode }}
</span>
<CommonIcon
size="xs"
decorative
:class="{
'text-white dark:text-white': isOpen,
'group-hover:text-black dark:group-hover:text-white': !isOpen,
}"
:name="isOpen ? 'chevron-up' : 'chevron-down'"
/>
</template>
</CommonButton>
</template>
</CommonDropdown>
<!-- :TODO -->
<!-- <CommonActionMenu
button-size="medium"
role="presentation"
class="flex! h-full items-center"
:custom-menu-button-label="$t('Additional actions')"
no-single-action-mode
placement="end"
hide-arrow
/> -->
{{ title }}
</CommonLabel>
</div>
<!-- c -->
<CommonLabel
class="col-span-2 mx-auto w-full max-w-[calc(var(--container-7xl)-2.750rem)] text-xl font-medium text-black dark:text-white"
tag="span"
>
{{ title }}
</CommonLabel>
<div v-if="$slots.details" class="col-span-2" :class="contentOuterClass">
<div class="mx-auto w-full" :class="contentWidthClass">
<slot name="details" />
</div>
</div>
</header>
</template>

View File

@@ -1,12 +1,35 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
import {
HEADER_CONTENT_OUTER_CLASSES,
HEADER_CONTENT_WIDTH_CLASSES,
HEADER_ROWS_CLASS,
HEADER_ROWS_WITH_DETAILS_CLASS,
} from './headerClasses.ts'
import { type HeaderContentWidth } from './types.ts'
// Mirrors the detail row of the answer header, so the skeleton has the same
// height as the header it stands in for.
const props = withDefaults(
defineProps<{ withDetails?: boolean; contentWidth?: HeaderContentWidth }>(),
{
contentWidth: 'wide',
},
)
const contentWidthClass = computed(() => HEADER_CONTENT_WIDTH_CLASSES[props.contentWidth])
const contentOuterClass = computed(() => HEADER_CONTENT_OUTER_CLASSES[props.contentWidth])
</script>
<template>
<header
class="grid w-full grid-cols-[1fr_min-content] grid-rows-2 gap-x-2 gap-y-2.5 border-b border-neutral-100 bg-neutral-50/80 px-5.5 py-3 backdrop-blur-2xs dark:border-gray-900 dark:bg-gray-500/80"
class="grid w-full grid-cols-[1fr_min-content] gap-x-2 gap-y-2.5 border-b border-neutral-100 bg-neutral-50/80 px-5.5 py-3 backdrop-blur-2xs dark:border-gray-900 dark:bg-gray-500/80"
:class="withDetails ? HEADER_ROWS_WITH_DETAILS_CLASS : HEADER_ROWS_CLASS"
>
<div class="flex h-6 items-center gap-1.5">
<CommonSkeleton class="size-3.5 shrink-0" rounded />
@@ -18,8 +41,17 @@ import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vu
<CommonSkeleton class="h-6 w-12" />
</div>
<div class="col-span-2 mx-auto w-full max-w-[calc(var(--container-7xl)-2.750rem)]">
<CommonSkeleton class="h-6 w-64" />
<div class="col-span-2" :class="contentOuterClass">
<div class="mx-auto w-full" :class="contentWidthClass">
<CommonSkeleton class="h-6 w-64" />
</div>
</div>
<div v-if="withDetails" class="col-span-2" :class="contentOuterClass">
<div class="mx-auto flex w-full gap-2.5" :class="contentWidthClass">
<CommonSkeleton class="h-7 w-24" rounded />
<CommonSkeleton class="h-7 w-56" rounded />
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,112 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { toRef } from 'vue'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonDropdown from '#desktop/components/CommonDropdown/CommonDropdown.vue'
import type { DropdownItem } from '#desktop/components/CommonDropdown/types.ts'
import KnowledgeBaseBreadcrumb from './KnowledgeBaseBreadcrumb.vue'
import { useTopBarHeader } from './useTopBarHeader.ts'
import type { TopBarHeaderProps } from './types.ts'
// The row shared by the full and the compact header: breadcrumb, copy button and
// the controls toolbar. Two root elements, so the full header can keep them as
// direct children of its grid while the compact header lays them out in a flex row.
const props = withDefaults(
defineProps<
TopBarHeaderProps & {
variant?: 'full' | 'compact'
copyLabel?: string
}
>(),
{
variant: 'full',
copyLabel: __('Copy knowledge base name'),
},
)
defineOptions({ inheritAttrs: false })
const selectedLocale = defineModel<DropdownItem>('selectedLocale')
const { copyKnowledgeBaseNameToClipboard } = useTopBarHeader(toRef(props))
</script>
<template>
<KnowledgeBaseBreadcrumb
:class="variant === 'compact' ? 'flex h-6 grow' : 'flex'"
:items="breadcrumbs"
size="small"
emphasize-last-item
:label="__('Knowledge base navigation')"
>
<template #trailing>
<CommonButton
v-if="breadcrumbs.length > 1"
v-tooltip="$t(copyLabel)"
variant="secondary"
icon="files"
size="small"
class="ms-1 print:hidden"
@click="copyKnowledgeBaseNameToClipboard"
/>
</template>
</KnowledgeBaseBreadcrumb>
<div role="toolbar" :aria-label="$t('Knowledge base controls')" class="flex items-center gap-2.5">
<slot name="stepper" />
<CommonLink
v-if="previewUrl"
v-tooltip="$t('View public knowledge base')"
:link="previewUrl"
external
open-in-new-tab
class="rounded-lg! bg-green-200 p-1 outline outline-offset-0! outline-neutral-100 hover:bg-green-200 hover:outline-blue-600 dark:bg-gray-600 dark:outline-gray-900 dark:hover:bg-gray-600 dark:hover:outline-blue-900 print:hidden"
size="small"
>
<CommonIcon size="tiny" name="box-arrow-in-up-right" />
</CommonLink>
<CommonDropdown v-model="selectedLocale" :items="locales" orientation="bottom">
<template #trigger="{ toggle, isOpen }">
<CommonButton
v-tooltip="$t('Change language')"
variant="none"
size="small"
class="rounded-lg bg-green-200! p-1! outline! outline-offset-0! outline-neutral-100 hover:bg-green-200 dark:bg-gray-600! dark:outline-gray-900 dark:hover:bg-gray-600"
:class="{ 'outline-blue-800!': isOpen }"
@click="toggle"
>
{{ localeCode }}
<template #label>
<span class="truncate">
{{ localeCode }}
</span>
<CommonIcon
size="xs"
decorative
class="text-gray-100 dark:text-neutral-400"
:name="isOpen ? 'chevron-up' : 'chevron-down'"
/>
</template>
</CommonButton>
</template>
</CommonDropdown>
<!-- :TODO -->
<!-- <CommonActionMenu
button-size="medium"
role="presentation"
class="flex! h-full items-center"
:custom-menu-button-label="$t('Additional actions')"
no-single-action-mode
placement="end"
hide-arrow
/> -->
</div>
</template>

View File

@@ -0,0 +1,149 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, toRef, useTemplateRef, type Ref } from 'vue'
import CommonAlert from '#shared/components/CommonAlert/CommonAlert.vue'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
import { HEADER_CONTENT_OUTER_CLASSES, HEADER_CONTENT_WIDTH_CLASSES } from './headerClasses.ts'
import { type HeaderContentWidth } from './types.ts'
interface Props {
contentContainerElement: HTMLElement | null
// The page's content-loading state, so the header can skeleton in lockstep
// instead of flashing stale data.
loading?: boolean
// Docks a warning alert below the header. Header and alert form one block that
// slides and sticks together for the full and the compact header alike, like
// the ticket detail top bar's channel alert.
alertMessage?: string
contentWidth?: HeaderContentWidth
}
const props = withDefaults(defineProps<Props>(), {
contentWidth: 'wide',
})
const contentWidthClass = computed(() => HEADER_CONTENT_WIDTH_CLASSES[props.contentWidth])
const contentOuterClass = computed(() => HEADER_CONTENT_OUTER_CLASSES[props.contentWidth])
// Only dock the alert once the content has resolved; while loading the header
// skeletons and there is no settled state to warn about yet.
const showAlert = computed(() => Boolean(props.alertMessage) && !props.loading)
const { height: fullBlockHeight } = useElementSize(useTemplateRef('full-wrapper'), undefined, {
box: 'border-box',
})
const { height: compactBlockHeight } = useElementSize(
useTemplateRef('compact-wrapper'),
undefined,
{
box: 'border-box',
},
)
const { y } = useElementScroll(toRef(props, 'contentContainerElement') as Ref<HTMLDivElement>)
// The compact header is absolutely positioned, and its containing block is not the
// main content column: the nearest positioned ancestor is `#main-content`, which
// spans the content sidebar too — and an absolutely positioned box is not clipped
// by ancestors that are not its containing block. So pin it to the measured column
// width, as the ticket detail top bar does; `left`/`right`/`width` together are
// over-constrained, and the width wins.
const { width: contentContainerWidth } = useElementSize(toRef(props, 'contentContainerElement'))
const compactHeaderWidth = computed(() =>
contentContainerWidth.value ? `${contentContainerWidth.value}px` : 'auto',
)
// Show the header earlier to always have it visible
const NEGATIVE_PADDING = -30
const compactHeaderOffset = computed(() => y.value - (fullBlockHeight.value + NEGATIVE_PADDING))
const hasMeasuredHeaderHeights = computed(
() => fullBlockHeight.value > 0 && compactBlockHeight.value > 0,
)
// The compact header is stacked above the full header (higher z-index), so once it has fully
// slid into place it takes over. Visibility and interactivity/a11y exposure are switched over
// at the exact same point, so exactly one header is ever shown/focusable/clickable/announced.
const isCompactHeaderVisible = computed(
() => hasMeasuredHeaderHeights.value && compactHeaderOffset.value > 0,
)
const absoluteContainerOffset = computed(
() => `${isCompactHeaderVisible.value ? 0 : compactHeaderOffset.value}px`,
)
// Pull the whole block — header plus any docked alert — clear of the viewport, so
// none of it lingers behind the compact header, whose background is translucent.
const stickyContainerTop = computed(
() => `${-Math.max(0, Math.min(y.value, fullBlockHeight.value))}px`,
)
const alertBaseClasses = 'rounded-none md:grid-cols-none md:justify-center'
const alertWithBlurClasses = `${alertBaseClasses} opacity-95 backdrop-blur-2xs`
</script>
<template>
<div
ref="compact-wrapper"
class="absolute inset-x-0 top-0 z-30 print:hidden"
data-test-id="knowledge-base-header-compact"
:style="{
transform: `translateY(${absoluteContainerOffset})`,
width: compactHeaderWidth,
}"
>
<slot v-if="!loading" name="compact" :inert="!isCompactHeaderVisible" />
<CommonAlert v-if="showAlert" class="px-5.5!" :class="alertWithBlurClasses" variant="warning">
{{ alertMessage }}
</CommonAlert>
</div>
<CommonLoader class="w-full" :loading="loading">
<!-- The compact header docks 30px before this block has fully slid out, and its
translucent background would let the remainder — most visibly a docked alert —
show through. Hide it rather than shrink that head start, so the compact header
still appears early. `visibility` keeps the block measured. -->
<div
ref="full-wrapper"
class="sticky inset-x-0 top-0 z-10 w-full print:visible! print:static"
:class="{ invisible: isCompactHeaderVisible }"
data-test-id="knowledge-base-header-full"
:style="{
top: stickyContainerTop,
}"
>
<slot name="full" :inert="isCompactHeaderVisible" />
<div v-if="showAlert" class="flex justify-center bg-yellow-50 px-5.5 dark:bg-yellow-900">
<CommonAlert
class="basis-full px-0! print:hidden"
:class="[alertBaseClasses, contentOuterClass, contentWidthClass]"
variant="warning"
>
{{ alertMessage }}
</CommonAlert>
</div>
</div>
<template #skeleton>
<div
class="sticky inset-x-0 top-0 z-10 w-full"
:style="{
top: stickyContainerTop,
}"
>
<slot name="skeleton" />
</div>
</template>
</CommonLoader>
</template>

View File

@@ -0,0 +1,131 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import KnowledgeBaseAnswerHeaderDetails from '../KnowledgeBaseAnswerHeaderDetails.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
const CURRENT_USER_ID = convertToGraphQLId('User', 1)
const OTHER_USER_ID = convertToGraphQLId('User', 2)
const answer = (overrides: Partial<KnowledgeBaseAnswerHeader> = {}): KnowledgeBaseAnswerHeader =>
({
id: convertToGraphQLId('KnowledgeBase::Answer', 1),
title: 'Some Answer',
visibility: EnumKnowledgeBaseVisibility.Published,
translationMissing: false,
internalAt: null,
publishedAt: null,
archivedAt: null,
editedAt: null,
editedBy: null,
category: { id: convertToGraphQLId('KnowledgeBase::Category', 1), breadcrumb: [] },
...overrides,
}) as KnowledgeBaseAnswerHeader
const renderDetails = (overrides: Partial<KnowledgeBaseAnswerHeader> = {}) =>
renderComponent(KnowledgeBaseAnswerHeaderDetails, {
props: { answer: answer(overrides) },
store: true,
router: true,
})
describe('KnowledgeBaseAnswerHeaderDetails', () => {
it.each([
[EnumKnowledgeBaseVisibility.Draft, 'Draft'],
[EnumKnowledgeBaseVisibility.Internal, 'Internal'],
[EnumKnowledgeBaseVisibility.Published, 'Published'],
[EnumKnowledgeBaseVisibility.Archived, 'Archived'],
])('labels the %s visibility badge', (visibility, label) => {
const view = renderDetails({ visibility })
expect(view.getByText(label)).toBeInTheDocument()
})
it('shows only the publication dates the answer actually carries', () => {
const view = renderDetails({ publishedAt: '2026-08-01T10:00:00Z' })
expect(view.getByText('Published at')).toBeInTheDocument()
expect(view.queryByText('Internally published at')).not.toBeInTheDocument()
expect(view.queryByText('Archived at')).not.toBeInTheDocument()
})
it('shows the internal and archival dates when they are set', () => {
const view = renderDetails({
visibility: EnumKnowledgeBaseVisibility.Archived,
internalAt: '2026-07-01T10:00:00Z',
archivedAt: '2026-08-05T10:00:00Z',
})
expect(view.getByText('Internally published at')).toBeInTheDocument()
expect(view.getByText('Archived at')).toBeInTheDocument()
})
// What a user without knowledge base permission receives: the backend nulls
// the internal lifecycle, leaving the badge and the publication date.
it('shows only the publication date for a public reader', () => {
const view = renderDetails({
publishedAt: '2026-08-01T10:00:00Z',
internalAt: null,
archivedAt: null,
editedAt: null,
editedBy: null,
})
expect(view.getByText('Published')).toBeInTheDocument()
expect(view.getByText('Published at')).toBeInTheDocument()
expect(view.queryByText('Internally published at')).not.toBeInTheDocument()
expect(view.queryByText('Archived at')).not.toBeInTheDocument()
expect(view.queryByText(/edited/)).not.toBeInTheDocument()
})
it('renders no edit chip without an edit date', () => {
const view = renderDetails()
expect(view.queryByText(/edited/)).not.toBeInTheDocument()
})
it('names the editor of the answer translation', () => {
const view = renderDetails({
editedAt: '2026-08-01T10:00:00Z',
editedBy: {
__typename: 'User',
id: OTHER_USER_ID,
firstname: 'Erika',
lastname: 'Mustermann',
fullname: 'Erika Mustermann',
},
})
expect(view.getByText(/edited .* by Erika Mustermann/)).toBeInTheDocument()
})
it('addresses the current user as "me"', () => {
mockUserCurrent({ id: CURRENT_USER_ID })
const view = renderDetails({
editedAt: '2026-08-01T10:00:00Z',
editedBy: {
__typename: 'User',
id: CURRENT_USER_ID,
firstname: 'Nicole',
lastname: 'Braun',
fullname: 'Nicole Braun',
},
})
expect(view.getByText(/edited .* by me/)).toBeInTheDocument()
})
it('falls back to the bare date when the editor is not disclosed', () => {
const view = renderDetails({ editedAt: '2026-08-01T10:00:00Z', editedBy: null })
expect(view.getByText(/^edited /)).toBeInTheDocument()
expect(view.queryByText(/ by /)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,109 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import buildIconsQueries from '#tests/support/components/iconQueries.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { EnumTextDirection } from '#shared/graphql/types.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import KnowledgeBaseAnswerStepper, { type Props } from '../KnowledgeBaseAnswerStepper.vue'
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
const PREVIOUS_ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 4)
const NEXT_ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 6)
type Navigation = NonNullable<KnowledgeBaseAnswerHeader['navigation']>
const navigation: Navigation = {
__typename: 'KnowledgeBaseAnswerNavigation',
index: 6,
totalCount: 17,
previousAnswer: {
__typename: 'KnowledgeBaseAnswer',
id: PREVIOUS_ANSWER_ID,
title: 'Previous answer',
},
nextAnswer: {
__typename: 'KnowledgeBaseAnswer',
id: NEXT_ANSWER_ID,
title: 'Next answer',
},
}
const renderStepper = (props: Partial<Props> = {}) =>
renderComponent(KnowledgeBaseAnswerStepper, {
router: true,
routerRoutes: [
{ path: '/', component: { template: '<div />' } },
{
name: 'KnowledgeBaseAnswer',
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId',
component: { template: '<div />' },
},
],
props: {
navigation,
localeCode: 'en-us',
...props,
},
})
describe('KnowledgeBaseAnswerStepper', () => {
it('renders the position and accessible neighbor labels', () => {
const view = renderStepper()
const answerNavigation = view.getByRole('navigation', { name: 'Answer navigation' })
expect(answerNavigation).toBeInTheDocument()
expect(answerNavigation).toHaveTextContent('6/17')
expect(view.getByRole('link', { name: 'Previous answer: Previous answer' })).toBeInTheDocument()
expect(view.getByRole('link', { name: 'Next answer: Next answer' })).toBeInTheDocument()
})
it('does not render for a single answer', () => {
const view = renderStepper({
navigation: { ...navigation, totalCount: 1 },
})
expect(view.queryByRole('navigation', { name: 'Answer navigation' })).not.toBeInTheDocument()
})
it('routes to each neighbor', async () => {
const view = renderStepper()
await view.events.click(view.getByRole('link', { name: 'Previous answer: Previous answer' }))
await waitFor(() => {
expect(view).toHaveCurrentUrl(
`/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(PREVIOUS_ANSWER_ID)}`,
)
})
await view.events.click(view.getByRole('link', { name: 'Next answer: Next answer' }))
await waitFor(() => {
expect(view).toHaveCurrentUrl(
`/knowledge-base/locale/en-us/answer/${getIdFromGraphQLId(NEXT_ANSWER_ID)}`,
)
})
})
it('reverses the chevrons in RTL locales', async () => {
const view = renderStepper()
const locale = useLocaleStore()
const previousLink = view.getByRole('link', { name: 'Previous answer: Previous answer' })
const nextLink = view.getByRole('link', { name: 'Next answer: Next answer' })
expect(buildIconsQueries(previousLink).getByIconName('chevron-left')).toBeInTheDocument()
expect(buildIconsQueries(nextLink).getByIconName('chevron-right')).toBeInTheDocument()
locale.localeData = { dir: EnumTextDirection.Rtl } as never
await waitFor(() => {
expect(buildIconsQueries(previousLink).getByIconName('chevron-right')).toBeInTheDocument()
expect(buildIconsQueries(nextLink).getByIconName('chevron-left')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,254 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { EnumKnowledgeBaseVisibility, EnumTextDirection } from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import KnowledgeBaseBreadcrumb from '../KnowledgeBaseBreadcrumb.vue'
import type { KnowledgeBaseBreadcrumbItem } from '../../../types.ts'
const items: KnowledgeBaseBreadcrumbItem[] = [
{ label: 'Knowledge Base', route: '/' },
{ label: 'Some Category' },
]
const renderBreadcrumb = (props = {}) =>
renderComponent(KnowledgeBaseBreadcrumb, {
props: {
items,
...props,
},
slots: {
trailing: 'trailing slot',
},
router: true,
})
// The category icons come from a sprite file per icon set, so they are plain
// `<use>` references and cannot be found via `getByIconName`.
const getCategoryIcons = (container: Element) =>
Array.from(container.querySelectorAll('use[href^="/assets/icon-fonts/"]')).map((use) =>
use.getAttribute('href'),
)
describe('KnowledgeBaseBreadcrumb', () => {
it('renders the breadcrumb', () => {
const view = renderBreadcrumb()
const link = view.getByRole('link', { name: 'Knowledge Base' })
expect(link).toHaveAttribute('href', '/desktop/')
expect(link).not.toHaveAttribute('aria-label')
expect(view.getByRole('heading', { name: 'Some Category', level: 1 })).toHaveAttribute(
'aria-current',
'page',
)
expect(view.getByText('trailing slot')).toBeInTheDocument()
})
it('translates the item labels unless asked not to', () => {
i18n.setTranslationMap(
new Map([
['Some Category', 'Irgendeine Kategorie'],
['Other Category', 'Andere Kategorie'],
]),
)
const view = renderBreadcrumb({
items: [
{ label: 'Some Category' },
{ label: 'Other Category', noOptionLabelTranslation: true },
],
})
expect(view.getByText('Irgendeine Kategorie')).toBeInTheDocument()
expect(view.getByText('Other Category')).toBeInTheDocument()
expect(view.queryByText('Andere Kategorie')).not.toBeInTheDocument()
i18n.setTranslationMap(new Map([]))
})
it('renders a count badge when a count is given', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/' },
{ label: 'Some Category', count: 0 },
],
})
expect(view.getByText('0')).toBeInTheDocument()
})
it('emphasizes the last item', () => {
const view = renderBreadcrumb({ emphasizeLastItem: true })
expect(view.getByText('Some Category').parentElement).toHaveClass(
'last:dark:text-white last:text-black',
)
})
it('supports different text sizes', async () => {
const view = renderBreadcrumb()
// Default size
expect(view.getByLabelText('Breadcrumb navigation')).toHaveClass('text-base')
await view.rerender({ items, size: 'small' })
expect(view.getByLabelText('Breadcrumb navigation')).toHaveClass('text-xs')
})
it('supports a custom navigation label', () => {
const view = renderBreadcrumb({ label: 'Category navigation' })
expect(view.getByRole('navigation', { name: 'Category navigation' })).toBeInTheDocument()
})
it('supports setting an item to isActive', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/' },
{ label: 'Some Category', isActive: true },
],
})
expect(view.getByRole('heading', { name: 'Some Category', level: 1 })).toHaveClass(
'text-black dark:text-white',
)
})
it('renders a separator between the items only', () => {
const view = renderBreadcrumb({
items: [...items, { label: 'Some Answer' }],
})
expect(view.getAllByIconName('chevron-right')).toHaveLength(2)
})
it('reverses the separator in RTL locales', async () => {
const view = renderBreadcrumb()
const locale = useLocaleStore()
expect(view.getByIconName('chevron-right')).toBeInTheDocument()
locale.localeData = { dir: EnumTextDirection.Rtl } as never
await waitFor(() => {
expect(view.getByIconName('chevron-left')).toBeInTheDocument()
})
locale.localeData = null
})
describe('icons', () => {
it('renders a common icon for an item without an icon set', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/', icon: 'book' },
{ label: 'Some Category', icon: 'folder' },
],
})
expect(view.getByIconName('book')).toBeInTheDocument()
expect(view.getByIconName('folder')).toBeInTheDocument()
expect(getCategoryIcons(view.container)).toEqual([])
})
it('renders the category icon from the given icon set', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/', icon: 'f115', iconSet: 'FontAwesome' },
{ label: 'Some Category', icon: 'f004', iconSet: 'FontAwesome' },
],
})
expect(getCategoryIcons(view.container)).toEqual([
'/assets/icon-fonts/FontAwesome.svg#icon-f115',
'/assets/icon-fonts/FontAwesome.svg#icon-f004',
])
})
it('renders the category icon for an icon only item', () => {
const view = renderBreadcrumb({
items: [
{
label: 'Knowledge Base',
route: '/',
icon: 'f115',
iconSet: 'FontAwesome',
iconOnly: true,
},
{ label: 'Some Category' },
],
})
expect(getCategoryIcons(view.container)).toContain(
'/assets/icon-fonts/FontAwesome.svg#icon-f115',
)
expect(view.getByRole('link')).toHaveAttribute('aria-label', 'Knowledge Base')
expect(view.queryByText('Knowledge Base')).not.toBeInTheDocument()
})
it('shows the visibility status of the category icon', () => {
const view = renderBreadcrumb({
items: [
{
label: 'Knowledge Base',
route: '/',
icon: 'f115',
iconSet: 'FontAwesome',
visibility: EnumKnowledgeBaseVisibility.Internal,
},
{
label: 'Some Category',
icon: 'f004',
iconSet: 'FontAwesome',
visibility: EnumKnowledgeBaseVisibility.Draft,
},
],
})
expect(view.getByLabelText('Internal')).toBeInTheDocument()
expect(view.getByIconName('lock-fill')).toBeInTheDocument()
expect(view.getByLabelText('Draft')).toBeInTheDocument()
expect(view.getByIconName('pencil-fill')).toBeInTheDocument()
})
it('shows no visibility status without a visibility', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/', icon: 'f115', iconSet: 'FontAwesome' },
{ label: 'Some Category' },
],
})
expect(view.queryByIconName('lock-fill')).not.toBeInTheDocument()
expect(view.queryByIconName('unlock-fill')).not.toBeInTheDocument()
expect(view.queryByIconName('pencil-fill')).not.toBeInTheDocument()
})
it('supports passing a custom class to the icons', () => {
const view = renderBreadcrumb({
items: [
{ label: 'Knowledge Base', route: '/', icon: 'book', iconClass: 'text-red-500' },
{
label: 'Some Category',
icon: 'f004',
iconSet: 'FontAwesome',
iconClass: 'text-blue-500',
},
],
})
expect(view.getByIconName('book')).toHaveClass('text-red-500')
// The custom class lands on the status wrapper, which colors the icon inside.
expect(view.container.querySelector('svg.icon-f004')?.parentElement).toHaveClass(
'text-blue-500',
)
})
})
})

Some files were not shown because too many files have changed in this diff Show More