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:
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ const scalars = {
|
||||
ISO8601Date: 'string',
|
||||
ISO8601DateTime: 'string',
|
||||
JSON: 'any',
|
||||
KnowledgeBaseIconSet: "'anticon' | 'FontAwesome' | 'material' | 'ionicons' | 'Simple-Line-Icons'",
|
||||
UriHttpString: 'string',
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
17
app/frontend/apps/desktop/components/CommonFileList/types.ts
Normal file
17
app/frontend/apps/desktop/components/CommonFileList/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ const defaultCollapsedState = () => ({
|
||||
[SidebarName.TicketContent]: false,
|
||||
[SidebarName.TicketOverviews]: false,
|
||||
[SidebarName.PersonalSetting]: false,
|
||||
[SidebarName.KnowledgeBaseAnswer]: false,
|
||||
})
|
||||
|
||||
export const useSidebarDisplayStore = defineStore('sidebarDisplay', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum SidebarName {
|
||||
TicketContent = 'ticket-content',
|
||||
TicketOverviews = 'ticket-overviews',
|
||||
PersonalSetting = 'personal-setting',
|
||||
KnowledgeBaseAnswer = 'knowledge-base-answer',
|
||||
}
|
||||
|
||||
export interface ToggleOptions {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,6 @@ export const KnowledgeBaseAnswerTranslationFragmentDoc = gql`
|
||||
category {
|
||||
id
|
||||
title
|
||||
knowledgeBase {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
kbLocale {
|
||||
|
||||
@@ -18,9 +18,6 @@ fragment knowledgeBaseAnswerTranslation on KnowledgeBaseAnswerTranslation {
|
||||
category {
|
||||
id
|
||||
title
|
||||
knowledgeBase {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
kbLocale {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const KnowledgeBaseCategoryPreInfoFragmentDoc = gql`
|
||||
id
|
||||
title
|
||||
categoryIcon
|
||||
iconSet
|
||||
visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ fragment knowledgeBaseCategoryPreInfo on KnowledgeBaseCategory {
|
||||
id
|
||||
title
|
||||
categoryIcon
|
||||
iconSet
|
||||
visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,12 @@
|
||||
subscription knowledgeBaseAnswerUpdates(
|
||||
$answerId: ID!
|
||||
$locale: String
|
||||
$initial: Boolean = false
|
||||
) {
|
||||
knowledgeBaseAnswerUpdates(answerId: $answerId, locale: $locale, initial: $initial) {
|
||||
answer {
|
||||
id
|
||||
...knowledgeBaseAnswerAttributes
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
5
app/frontend/apps/desktop/initializer/assets/zoom-in.svg
Normal file
5
app/frontend/apps/desktop/initializer/assets/zoom-in.svg
Normal 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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -7,7 +7,7 @@ withDefaults(
|
||||
index?: number
|
||||
}>(),
|
||||
{
|
||||
index: 0,
|
||||
index: 1,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user