Compare commits

...

42 Commits

Author SHA1 Message Date
Florian Liebe
9a69c6b85b Release Zammad 7.2.0. 🚀 2026-09-23 07:04:19 +02:00
Mantas Masalskis
a0403dfc9f Maintenance: Desktop view - Cannot create API token. 2026-09-22 23:22:58 +03:00
Rolf Schmidt
fc5b811f11 Maintenance: Migrating an existing multiselect attribute raises an ArgumentError.
(cherry picked from commit 9ff306fced)

fc3b23cd Maintenance: Migrating an existing multiselect attribute raises an ArgumentError.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-22 15:38:33 +02:00
Dusan Vuckovic
44a5dd764f Follow-up b3075fe7d5 - Maintenance: Improve readability of HTTP log entries for Webhooks and AI providers. 2026-09-22 13:42:53 +01:00
Marcel Bialas
30e0b14ddf Maintenance: No password_change notification when password is set via the user-update endpoint 2026-09-22 11:32:51 +02:00
Marcel Bialas
cf65e3b09b Fixes #6369 - Knowledge Base wildcard search shows escaped special characters in titles. 2026-09-22 09:52:33 +02:00
Weblate
9d2c6cf523 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anael Mobilia <contrib@anael.eu>
Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Igor Costa <igor_sfx@yahoo.com.br>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-09-22 05:37:06 +00:00
Martin Gruner
280693014a Follow-up 670a1e13fe - Fixes #6310 - Audit log: untranslated values in object type column
Approved-by: Martin Gruner <mg@zammad.com>


(cherry picked from commit 76a2bec1f0)

93db90de Follow-up 670a1e13fe - Fixes #6310 - Audit log:...

Co-authored-by: Mantas Masalskis <mm@zammad.com>
2026-09-22 07:36:29 +02:00
Dusan Vuckovic
5bd572d2ac Fixes #6370 - Core workflow ignores default value if select attribute options are mutated. 2026-09-21 15:56:21 +01:00
Mantas Masalskis
fac22c1cf6 Fixes #6363 - Desktop view: a date or datetime attribute marks every ticket tab as changed. 2026-09-21 16:08:08 +03:00
Benjamin Scharf
7689d03b89 Fixes #6372 - Ticket draft gets duplicated everytime the Browser back button is used. 2026-09-21 11:37:57 +02:00
Rolf Schmidt
e14dae3f7b Maintenance: Cover the staged package check in the pending migrations initializer for Kubernetes deployments.
Approved-by: Martin Gruner <mg@zammad.com>


(cherry picked from commit c545ff10b2)

7cbb9c79 Maintenance: Cover the staged package check in the pending migrations...

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-18 15:14:54 +02:00
Rolf Schmidt
54f25639d3 Maintenance: Wait for staged addon packages before serving in container environments.
Approved-by: Dusan Vuckovic <dv@zammad.com>


(cherry picked from commit 301f2b11e1)

48185e1a Maintenance: Wait for staged addon packages before serving in container environments.
cea7c3ca Maintenance: Mark the pending package messages as translatable.
cee39cb6 Maintenance: Use guard clauses and untranslated messages for the staged package check.
c0a75ba1 Maintenance: Use guard clauses with extracted predicates for the staged package checks.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-18 13:05:32 +02:00
Rolf Schmidt
6d2da5843f Maintenance: Core Workflow - Do not show attributes without matching screen permission options.
Approved-by: Florian Liebe <fl@zammad.com>


(cherry picked from commit 847138afaa)

d95d143a Maintenance: Core workflow - Do not show attributes without matching screen permission options.
c36b978f Maintenance: Core Workflow - Only remove attributes without matching...
a234ea33 Maintenance: Object Manager - Compare the ticket customer by id when resolving element screens.
c9d21f25 Maintenance: Policies - Compare the record user by id to support user context objects.
bafb76bf Maintenance: UserContext - Support ActiveRecord equality checks against the wrapped user.
6fe130cf Maintenance: Restore object comparisons for the current user now that...
ec0a304c Maintenance: Core Workflow - Add condition spec for session group ids with a user context.
7858d775 Maintenance: Core Workflow - Pin the visibility of screens that resolve to no options.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-18 12:05:43 +02:00
Rolf Schmidt
c495d84328 Feature: Show a read-only package overview in Docker environments.
Approved-by: Rolf Schmidt <rolf.schmidt@zammad.com>


(cherry picked from commit 925bea2505)

69448a70 Feature: Show a read-only package overview in Docker environments.

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-09-18 11:59:57 +02:00
Rolf Schmidt
b455ec4d37 Follow-up f6114a6aca - Maintenance: Allow building container images with preinstalled addon packages.
Approved-by: Martin Gruner <mg@zammad.com>


(cherry picked from commit 5124de7d3e)

42f69fb6 Follow-up f6114a6aca - Maintenance: Allow...

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-09-18 11:57:46 +02:00
Weblate
f21960b4d6 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Igor Costa <igor_sfx@yahoo.com.br>
Co-authored-by: Marcel Bialas <mtzl.29@proton.me>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translation: Zammad/Zammad (pre-release version)
2026-09-17 22:12:38 +00:00
Dusan Vuckovic
98a6bcc601 Follow-up for 5f01c16a75 - Feature: Desktop view - Add/Edit/Remove a Knowledge Base Category. 2026-09-17 16:02:43 +01:00
Martin Edenhofer
da8a8dd958 Maintenance: Fix publiccode.yml version, languages and links.
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Martin Edenhofer <me@edenhofer.de>
2026-09-17 15:05:28 +02:00
Benjamin Scharf
a4c23643eb Fixes #6367 - Knowledge Base next/previous buttons use original language. 2026-09-17 14:44:54 +02:00
Florian Liebe
886134ceb5 Maintenance: Resolve mocked GraphQL union fields by their declared type. 2026-09-17 12:55:56 +02:00
Mantas Masalskis
3f063a7014 Fixes #6340 - Mail body silently falls back to CharDet when Encoding.find... 2026-09-17 10:02:59 +03:00
Weblate
1976b06c01 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-09-16 06:57:36 +00:00
Weblate
25623e2928 Maintenance: Update translated files and catalog 2026-09-15 23:21:25 +01:00
Weblate
556937b6de Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anael Mobilia <contrib@anael.eu>
Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Igor Costa <igor_sfx@yahoo.com.br>
Co-authored-by: Murat Duman <mduman@metu.edu.tr>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: albanobattistella <albanobattistella@gmail.com>
Co-authored-by: k-fujimaru <k.fujimaru@natsuhara.link>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/ja/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/tr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-09-15 22:12:53 +00:00
Martin Gruner
8a7a138f4e Maintenance: Restore $VERBOSE after em-websocket monkey patches.
Approved-by: Florian Liebe <fl@zammad.com>


(cherry picked from commit 858e6fcf04)

6f082a48 Maintenance: Restore $VERBOSE after em-websocket monkey patches.
94e142cb Maintenance: Restore $VERBOSE in an ensure, via silence_warnings.
599e5526 Maintenance: Revert to the plain module nesting under silence_warnings.
e086995d Revert previous commit, simplify solution

Co-authored-by: Martin Gruner <mg@zammad.com>
2026-09-15 12:11:26 +02:00
Rolf Schmidt
252fa26d6f Maintenance: Load the models before generating the GraphQL introspection to include dynamic schema parts.
Approved-by: Dusan Vuckovic <dv@zammad.com>


(cherry picked from commit e8902e14f1)

9e9db3bc Maintenance: Load the models before generating the GraphQL introspection to...

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-15 10:34:23 +02:00
Rolf Schmidt
893ac7943e Maintenance: Allow building container images with preinstalled addon packages.
Approved-by: Martin Gruner <mg@zammad.com>

Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>


(cherry picked from commit f6114a6aca)

284237a6 add proof of concept for installing addon packages into container images
2efa76d1 install additional gems from addon packages during image build
2de39cdc refuse mutating package operations via rest api and cli in container environments
65179f86 Split the docker test job into a template and invocation
3ce5ca96 Fix docker ci script
e234bd0c remove auto install mechanism and register addon packages without file system...
5af40644 install addon packages in dependency order during container init
1cf34595 use pluck in addon package install script
96e58bdd add install_dir rake task to install addon packages from a directory in dependency order
d39deace Fix docker CI job and add one for addon testing
12cbdf60 Simplify Dockerfile to only generate one layer, try to fix CI
512a1785 Try to fix CI
840c22df Auto-start docker jobs during this MR
2036ac5b Fix docker test
6cd05602 document that the setting types catch-all is required for addon settings in container images
cf3b13f3 improve wording of setting types catch-all comment
1222960c simplify setting types catch-all comment
b2d9554b Read addon packages to unpack from packages/install/ during image build,...
3975ee12 Uninstall addon packages staged in packages/uninstall/ during container init.
436ed5aa fix: Unpack all files when uninstalling packages (incl. frontend).
c256e7fe WIP testing
293f7cac Fix file mode
f34ee186 Simplify docker job, don't require ruby
12ae1f4d Fix file path
202211d2 Wait for zammad-init to complete
90b84f7a Add debug output
41e27c94 Fix migration class name
489561d1 add optional build label to the docker version string
f56cf32d Drop file added by accident
e503ce81 Try to solve migration issue
570fefb3 test: Merge two cases together so the package can be uninstalled correctly.
b215f079 yolo: oops
967c5453 Simplify docker test job
da9fe3d9 WIP, drop docker push
a5011236 Fix test invocation
c034f6b8 WIP
b6e247ac Try readonly fs for CI
00de4360 Add tmpfs also for /tmp
9d58ed71 Try to fix tests in readonly fs
13b40e0e Drop readonly part again, we'll address this later on in the docker stack
de7c86bb Also test package dependencies
731af123 Fix setting names
d9e5cddb Typo
913c3492 Centralized container env detection
0ec84a8c doc: Drop superfluous page in dev manual.
0e666fb0 Make docker jobs manual again
2a41f6eb Maintenance: allow package upgrades of packages with dependents and remove spec file leftovers.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-09-15 10:34:06 +02:00
Dusan Vuckovic
c7623e7c68 Fixes #6355 - SLA times longer than 41 days cannot be edited via GUI. 2026-09-15 08:25:49 +01:00
Florian Liebe
a3a781ae36 Maintenance: Repair spec assertions that never ran or depend on the driver.
Approved-by: Florian Liebe <fl@zammad.com>

Co-authored-by: Dominik Klein <dk@zammad.com>
2026-09-14 12:40:02 +02:00
Philipp Reger
6f41176e43 Fixes #6357 - S/MIME decryption fails for forwarded emails when recipient is only present in Delivered-To. 2026-09-14 10:14:39 +01:00
Florian Liebe
1a9661dfc1 Maintenance: Fix api_client:php job by disabling PHPUnit coverage. 2026-09-11 22:25:56 +02:00
Benjamin Scharf
e0deaf1780 Fixes #6352 - Checked parameter ignored on POST checklist_items 2026-09-10 12:45:33 +02:00
Benjamin Scharf
94a6f01d75 Maintenance: Desktop view - Detail search triggers network requests on every keystroke
Approved-by: Marcel Bialas <mb@zammad.com>
2026-09-09 10:46:06 +01:00
Marcel Bialas
8bbd65fbb7 Feature: Desktop view - Add a knowledge base answers tab to the global detailed search
Approved-by: Benjamin Scharf <bs@zammad.com>
2026-09-09 10:45:50 +01:00
Benjamin Scharf
c16f6c57b9 Feature: Desktop view - Limit quick search results to 5 per entity type when...
Approved-by: Marcel Bialas <mb@zammad.com>
2026-09-09 10:45:40 +01:00
Benjamin Scharf
7c11c7bb5e Maintenance: Remove truncation logging from KnowledgeBase search
Approved-by: Mantas Masalskis <mm@zammad.com>
2026-09-09 10:45:25 +01:00
Benjamin Scharf
c6f201a923 Feature: Desktop view - Show knowledge base answers in quicksearch results
Approved-by: Mantas Masalskis <mm@zammad.com>
2026-09-09 10:45:09 +01:00
Dusan Vuckovic
17c7b30ef6 Maintenance: Show warning alert when editing Zammad AI provider in SaaS environment. 2026-09-08 15:52:39 +01:00
Dusan Vuckovic
876ea9ced5 Fixes #6305 - CSRF token verification failure during login after updating to Zammad 7.1 2026-09-04 15:07:39 +01:00
Dusan Vuckovic
e45d33852a Follow-up 61e0da447f - Feature: Desktop view - Follow-ups from the knowledge base answer review. 2026-09-04 11:03:45 +01:00
Florian Liebe
b20c13b78f Update version information for Zammad 7.2.0. 2026-09-03 22:55:58 +02:00
258 changed files with 25269 additions and 13637 deletions

View File

@@ -64,5 +64,12 @@ Do not "fix" these without a decision:
inactive knowledge base that way — the policy for anyone with effective access
to the record (a reader included), the scope for the `knowledge_base.editor`
permission.
- `Gql::Concerns::SearchesKnowledgeBaseAnswers` and
`Gql::Queries::KnowledgeBase::Search` answer with an **empty result** instead of
raising when no knowledge base is active. Both are reached from a document that
asks for more than the knowledge base — the desktop quicksearch requests every
searchable entity in one query — and the search field is `null: false`, so a
raise would null the root and take the ticket, user and organization groups down
with it on every instance that has no knowledge base.
Each of these carries the reasoning in a code comment; keep the two in sync.

2
.gitignore vendored
View File

@@ -123,6 +123,8 @@ schema.graphql
# Packages
*.zpm
!.gitlab/docker/*.zpm
/packages/
# Devcontainer
/.devcontainer/with-mailserver/certs/*

View File

@@ -30,7 +30,7 @@ api_client:php:
- ln -s /usr/local/bin/composer.phar /usr/local/bin/composer
- cd zammad-api-client-php
- composer install
- vendor/bin/phpunit
- vendor/bin/phpunit --no-coverage
api_client:ruby:
extends:

View File

@@ -1,4 +1,4 @@
docker:
.template_docker:
image: docker
stage: test
interruptible: false
@@ -12,37 +12,57 @@ docker:
cache: []
variables:
ZAMMAD_IMAGE_TAG: "ci-${CI_JOB_ID}-${CI_COMMIT_SHORT_SHA}"
ZAMMAD_IMAGE_REPO: "$CI_REGISTRY/zammad/zammad"
ZAMMAD_IMAGE_REPO: "zammad"
ZAMMAD_IMAGE: "${ZAMMAD_IMAGE_REPO}:${ZAMMAD_IMAGE_TAG}"
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- docker push $ZAMMAD_IMAGE
script: |
git clone --depth=1 https://github.com/zammad/zammad-docker-compose.git
cd zammad-docker-compose
- git clone --depth=1 https://github.com/zammad/zammad-docker-compose.git
# Make the stack use the image that was just built.
echo "IMAGE_REPO=${ZAMMAD_IMAGE_REPO}" > .env
echo "VERSION=${ZAMMAD_IMAGE_TAG}" >> .env
cat .env
- echo "IMAGE_REPO=${ZAMMAD_IMAGE_REPO}" > zammad-docker-compose/.env
- echo "VERSION=${ZAMMAD_IMAGE_TAG}" >> zammad-docker-compose/.env
- cat zammad-docker-compose/.env
# Use dynamic stack name and no exposed port to avoid conflicts.
cat - > docker-compose.override.yml <<COMPOSE_OVERRIDE_FILE
---
name: zammad-docker-ci-${ZAMMAD_IMAGE_TAG}
services:
zammad-nginx:
ports: !reset []
COMPOSE_OVERRIDE_FILE
.github/tests/setup/default.sh
DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
docker compose ps
# Ensure we are running with the correct image
docker compose ps | grep ${ZAMMAD_IMAGE}
- |
cat - > zammad-docker-compose/docker-compose.override.yml <<COMPOSE_OVERRIDE_FILE
---
name: zammad-docker-ci-${ZAMMAD_IMAGE_TAG}
services:
zammad-nginx:
ports: !reset []
COMPOSE_OVERRIDE_FILE
after_script:
- cd zammad-docker-compose
- docker compose down --timeout 0 --volumes
- docker image rm ${ZAMMAD_IMAGE}
docker base tests:
extends: .template_docker
script:
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- .github/tests/setup/default.sh
- DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
- docker compose ps
# Ensure we are running with the correct image
- docker compose ps | grep ${ZAMMAD_IMAGE}
docker addon management:
extends: .template_docker
script:
- mkdir -p packages/install packages/uninstall
# part 1: test package installation
- cp .gitlab/docker/*.zpm packages/install/
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- echo "ELASTICSEARCH_ENABLED=false" >> .env
- docker compose run --rm zammad-init
- |
docker compose run --rm zammad-railsserver bundle exec rails r "Setting.where(name: ['zammad_test_a_addon_up', 'zammad_test_b_addon_up']).count == 2 || raise('Setting not found')"
- cd ..
# part 2: test package uninstallation
- mv packages/install/*.zpm packages/uninstall
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- docker compose run --rm zammad-init
- |
docker compose run --rm zammad-railsserver bundle exec rails r "Setting.where(name: ['zammad_test_a_addon_down', 'zammad_test_b_addon_down']).count == 2 || raise('Setting not found')"

View File

@@ -0,0 +1,39 @@
---
name: ZammadTestA
version: 1.0.0
vendor: Zammad GmbH
license: GNU AFFERO GENERAL PUBLIC LICENSE
url: http://zammad.com/
change_log: []
dependencies:
ZammadTestB: ">= 1.0.0"
description:
- language: en
text: Test package for CI
- language: de
text: Testpaket für die CI
buildhost: zammad42
builddate: 2026-08-19 15:29:46 UTC
files:
- location: db/addon/zammad_test_a/20251112113130_zammad_test_a_settings.rb
permission: 644
content: |
class ZammadTestASettings < ActiveRecord::Migration[8.0]
def self.up
Setting.create_if_not_exists(
title: 'Zammad Test A Addon Up',
name: 'zammad_test_a_addon_up',
area: 'Core',
description: 'Verify that the addon was installed.',
)
end
def self.down
Setting.create_if_not_exists(
title: 'Zammad Test A Addon Down',
name: 'zammad_test_a_addon_down',
area: 'Core',
description: 'Verify that the addon was removed.',
)
end
end

View File

@@ -0,0 +1,31 @@
{
"name": "ZammadTestA",
"version": "1.0.0",
"vendor": "Zammad GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://zammad.com/",
"change_log": [],
"dependencies": {
"ZammadTestB": ">= 1.0.0"
},
"description": [
{
"language": "en",
"text": "Test package for CI"
},
{
"language": "de",
"text": "Testpaket für die CI"
}
],
"buildhost": "zammad42",
"builddate": "2026-08-19 15:29:46 UTC",
"files": [
{
"location": "db/addon/zammad_test_a/20251112113130_zammad_test_a_settings.rb",
"permission": 644,
"content": "Y2xhc3MgWmFtbWFkVGVzdEFTZXR0aW5ncyA8IEFjdGl2ZVJlY29yZDo6TWlncmF0aW9uWzguMF0KICBkZWYgc2VsZi51cAogICAgU2V0dGluZy5jcmVhdGVfaWZfbm90X2V4aXN0cygKICAgICAgdGl0bGU6ICAgICAgICdaYW1tYWQgVGVzdCBBIEFkZG9uIFVwJywKICAgICAgbmFtZTogICAgICAgICd6YW1tYWRfdGVzdF9hX2FkZG9uX3VwJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIGluc3RhbGxlZC4nLAogICAgKQogIGVuZAoKICBkZWYgc2VsZi5kb3duCiAgICBTZXR0aW5nLmNyZWF0ZV9pZl9ub3RfZXhpc3RzKAogICAgICB0aXRsZTogICAgICAgJ1phbW1hZCBUZXN0IEEgQWRkb24gRG93bicsCiAgICAgIG5hbWU6ICAgICAgICAnemFtbWFkX3Rlc3RfYV9hZGRvbl9kb3duJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIHJlbW92ZWQuJywKICAgICkKICBlbmQKZW5kCg==",
"encode": "base64"
}
]
}

View File

@@ -0,0 +1,37 @@
---
name: ZammadTestB
version: 1.0.0
vendor: Zammad GmbH
license: GNU AFFERO GENERAL PUBLIC LICENSE
url: http://zammad.com/
change_log: []
description:
- language: en
text: Test package for CI
- language: de
text: Testpaket für die CI
buildhost: zammad42
builddate: 2026-08-19 15:29:46 UTC
files:
- location: db/addon/zammad_test_b/20251112113130_zammad_test_b_settings.rb
permission: 644
content: |
class ZammadTestBSettings < ActiveRecord::Migration[8.0]
def self.up
Setting.create_if_not_exists(
title: 'Zammad Test B Addon Up',
name: 'zammad_test_b_addon_up',
area: 'Core',
description: 'Verify that the addon was installed.',
)
end
def self.down
Setting.create_if_not_exists(
title: 'Zammad Test B Addon Down',
name: 'zammad_test_b_addon_down',
area: 'Core',
description: 'Verify that the addon was removed.',
)
end
end

View File

@@ -0,0 +1,28 @@
{
"name": "ZammadTestB",
"version": "1.0.0",
"vendor": "Zammad GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://zammad.com/",
"change_log": [],
"description": [
{
"language": "en",
"text": "Test package for CI"
},
{
"language": "de",
"text": "Testpaket für die CI"
}
],
"buildhost": "zammad42",
"builddate": "2026-08-19 15:29:46 UTC",
"files": [
{
"location": "db/addon/zammad_test_b/20251112113130_zammad_test_b_settings.rb",
"permission": 644,
"content": "Y2xhc3MgWmFtbWFkVGVzdEJTZXR0aW5ncyA8IEFjdGl2ZVJlY29yZDo6TWlncmF0aW9uWzguMF0KICBkZWYgc2VsZi51cAogICAgU2V0dGluZy5jcmVhdGVfaWZfbm90X2V4aXN0cygKICAgICAgdGl0bGU6ICAgICAgICdaYW1tYWQgVGVzdCBCIEFkZG9uIFVwJywKICAgICAgbmFtZTogICAgICAgICd6YW1tYWRfdGVzdF9iX2FkZG9uX3VwJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIGluc3RhbGxlZC4nLAogICAgKQogIGVuZAoKICBkZWYgc2VsZi5kb3duCiAgICBTZXR0aW5nLmNyZWF0ZV9pZl9ub3RfZXhpc3RzKAogICAgICB0aXRsZTogICAgICAgJ1phbW1hZCBUZXN0IEIgQWRkb24gRG93bicsCiAgICAgIG5hbWU6ICAgICAgICAnemFtbWFkX3Rlc3RfYl9hZGRvbl9kb3duJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIHJlbW92ZWQuJywKICAgICkKICBlbmQKZW5kCg==",
"encode": "base64"
}
]
}

41
.gitlab/docker/yaml_to_zpm.rb Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
# Converts all package definitions in YAML form found next to this script to the
# JSON based .zpm format, written to a file of the same base name. The content of
# all entries in "files" is transcoded to base64.
#
# ruby yaml_to_zpm.rb
#
# ZammadTestA-1.0.0.yaml -> ZammadTestA-1.0.0.zpm
require 'base64'
require 'json'
require 'yaml'
def convert(source, target)
package = YAML.safe_load_file(source, aliases: true)
raise "Expected a YAML mapping as package definition in #{source}." if !package.is_a?(Hash)
Array(package['files']).each do |file|
next if !file.is_a?(Hash) || !file.key?('content')
file['encode'] = 'base64'
file['content'] = Base64.strict_encode64(file['content'].to_s)
end
File.write(target, "#{JSON.pretty_generate(package)}\n")
end
sources = Dir.glob('*.{yaml,yml}', base: __dir__).sort
raise 'No YAML package definitions found.' if sources.empty?
sources.each do |source|
target = "#{File.basename(source, '.*')}.zpm"
convert(File.join(__dir__, source), File.join(__dir__, target))
puts "#{source} -> #{target}"
end

View File

@@ -73,6 +73,34 @@ instead.
## 7.2
### Zammad's reverse proxy configuration is authoritative for the request scheme
**Who is affected?** Admins running the Docker images or a newly installed nginx behind a proxy
that reports the request scheme via the `Forwarded` or `X-Forwarded-Ssl` header, and admins of a
newly installed Apache behind any TLS-terminating proxy.
Rack evaluates `X-Forwarded-Ssl` and `Forwarded` with a higher priority than `X-Forwarded-Proto`, so
an outer proxy could silently override the scheme that Zammad's own web server configuration had set.
The nginx and Apache configurations shipped with Zammad therefore now clear `Forwarded`,
`X-Forwarded-Scheme` and `X-Forwarded-Ssl`, which leaves `X-Forwarded-Proto` as the only place where
the scheme is decided.
The Apache configurations did not set `X-Forwarded-Proto` at all before, so an instance behind an
SSL-terminating Apache was never seen as HTTPS. They now set it explicitly, to `http` in the non-SSL
and to `https` in the SSL virtual host, which also means an outer proxy's value is no longer passed
through.
The Docker images rebuild their nginx configuration from the shipped one on every start, so the change
applies to them automatically. Package installations keep the web server configuration they already
have; only newly installed ones receive the updated files.
⚠️ If Zammad was reachable over HTTPS only because the proxy in front of it sent `Forwarded` or
`X-Forwarded-Ssl` — or, on Apache, `X-Forwarded-Proto` — configure the scheme in Zammad's own web
server instead: set `NGINX_SERVER_SCHEME=https` for the Docker images, or the `X-Forwarded-Proto` line
in the nginx respectively Apache configuration file.
**Related issue:** [#6305](https://github.com/zammad/zammad/issues/6305)
### Links between an object and itself are rejected, and failed link creations answer `422`
**Who is affected?** Integrations that create links via `POST /api/v1/links/add`.
@@ -202,6 +230,29 @@ reverse proxy are blocked again by it.
## 7.1
### `X-Forwarded-Proto` takes precedence over `X-Forwarded-Scheme`
**Who is affected?** Admins running Zammad behind a reverse proxy that terminates TLS and reports the
request scheme via the `X-Forwarded-Scheme` header only, such as Nginx Proxy Manager.
Zammad was updated from Rack 2 to Rack 3, which changed which header decides whether a request is
seen as HTTPS: Rack 2 evaluated `X-Forwarded-Scheme` first, Rack 3 evaluates `X-Forwarded-Proto`
first. Zammad's own nginx configuration sets `X-Forwarded-Proto` from the scheme of the connection it
receives, in the Docker images via the `NGINX_SERVER_SCHEME` variable, which defaults to `$scheme`.
Behind a proxy that terminates TLS and forwards plain HTTP, that value is `http`, so such requests
are now seen as plain HTTP even though the proxy reported `X-Forwarded-Scheme: https`.
With the `http_type` setting set to `https`, the session cookie is marked `Secure`, and a `Secure`
cookie is not written on a request that is seen as plain HTTP. No session is created at all, and
logging in fails with "CSRF token verification failed!". Sessions that already hold a cookie keep
working, so the problem only surfaces after a logout.
⚠️ Configure the scheme in Zammad's own web server rather than in the proxy in front of it, whose
`X-Forwarded-Proto` header is overwritten: set `NGINX_SERVER_SCHEME=https` for the Docker images, or
the `X-Forwarded-Proto` line in the nginx configuration file.
**Related issue:** [#6305](https://github.com/zammad/zammad/issues/6305)
### Elasticsearch 7 was deprecated
**Who is affected?** Admins of systems that run Elasticsearch 7.

View File

@@ -1,8 +1,8 @@
# Change Log for Zammad 7.2.x
# Change Log for Zammad 7.2.0
- [Release notes](https://zammad.com/en/product/releases/zammad-7-2-x)
- [Release notes](https://zammad.com/en/product/releases/7-2)
- [Breaking changes](BREAKING_CHANGES.md#72)
- [Implemented enhancements](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A7.2+(-type%3ABug+AND+-label%3Abug))
- [Closed bugs](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A7.2+(type%3ABug+OR+label%3Abug))
- [Full commit log](https://github.com/zammad/zammad/compare/7.0.1...7.2.x)
- [File tree](https://github.com/zammad/zammad/tree/7.2.x)
- [Full commit log](https://github.com/zammad/zammad/compare/7.1.3...7.2.0)
- [File tree](https://github.com/zammad/zammad/tree/7.2.0)

View File

@@ -40,6 +40,9 @@ RUN npm -g install corepack && corepack enable pnpm && \
FROM base AS build
ARG COMMIT_SHA
# Optional additional build information, e.g. the customer image name when building
# images with addon packages. Shown in the version string of the instance.
ARG BUILD_LABEL
SHELL ["/bin/bash", "-o", "errexit", "-o", "pipefail", "-c"]
@@ -71,11 +74,38 @@ RUN if [ -z "${COMMIT_SHA}" ]; then \
echo "Error: the required build argument \$COMMIT_SHA is missing."; \
exit 1; \
fi; \
if ! [[ "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{8,40}$ ]]; then \
echo "Error: the build argument \$COMMIT_SHA must match [0-9a-fA-F]{8,40}, got '${COMMIT_SHA}'."; \
exit 1; \
fi; \
if [ -n "${BUILD_LABEL}" ] && ! [[ "${BUILD_LABEL}" =~ ^[0-9a-zA-Z_.-]{1,35}$ ]]; then \
echo "Error: the build argument \$BUILD_LABEL must match [0-9a-zA-Z_.-]{1,35}, got '${BUILD_LABEL}'."; \
exit 1; \
fi; \
COMMIT_SHA_SHORT=$(echo "${COMMIT_SHA}" | cut -c 1-8); \
echo "$(tr -d '\n' < VERSION)-${COMMIT_SHA_SHORT}.docker" > VERSION; \
echo "$(tr -d '\n' < VERSION)-${COMMIT_SHA_SHORT}${BUILD_LABEL:+.${BUILD_LABEL}}.docker" > VERSION; \
echo 'Updated build information in VERSION:'; \
cat VERSION
# Special handling for zpm addons, if present:
# - Unpack addon packages provided in packages/install/*.zpm (registered in the database on container start)
# and packages/uninstall/*.zpm (removed from the database on container start).
# - Install additional gems provided by addon packages (Gemfile.local.*).
# Deployment mode is disabled to allow updating the Gemfile.lock with the additional gems.
# - Regenerate the GraphQL frontend API in case addon packages extend the GraphQL schema.
# A rake task is used instead of 'rails generate' because rake skips eager loading,
# which would require a database connection.
RUN if compgen -G "packages/*/*.zpm" > /dev/null; then \
ruby script/build/unpack_addon_packages.rb; \
if compgen -G "Gemfile.local.*" > /dev/null; then \
BUNDLE_DEPLOYMENT=false bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache; \
fi; \
mkdir -p tmp && touch db/schema.rb; \
RAILS_LOG_TO_STDOUT= ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql://zammad:/zammad ZAMMAD_GRAPHQL_INTROSPECTION=true bundle exec rake zammad:graphql:introspection > app/graphql/graphql_introspection.json; \
pnpm exec graphql-codegen -c .graphql_code_generator.js; \
fi
# Don't require Redis or Postgres (use fake DATABASE_URL to make Rails validation happy).
RUN touch db/schema.rb && \
ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql://zammad:/zammad bundle exec rake assets:precompile

View File

@@ -1 +1 @@
7.2.x
7.2.0

View File

@@ -513,18 +513,27 @@ ProviderConnectionFormMixin =
# Type and name head the credential step, and only it: the provider is picked before anything
# is fetched with it, and the model step must not offer to change it behind the fetched list.
providerSelectionAttributes: (providerKey, excludeProviders) ->
sortedOptions = {}
Object
.entries(App.Config.get('AIProviders'))
.filter(([key, _]) -> key not in excludeProviders)
.sort(([_, a], [__, b]) -> a.prio - b.prio)
.forEach(([key, { label }]) -> sortedOptions[key] = label)
attributes = []
# Provider comes first: selecting it pre-fills the name below.
[
{ name: 'provider', display: __('Type'), tag: 'select', options: sortedOptions, null: false, nulloption: true, value: providerKey, customsort: 'on' }
if providerKey isnt 'zammad_ai' or not App.Config.get('system_online_service')
sortedOptions = {}
Object
.entries(App.Config.get('AIProviders'))
.filter(([key, _]) -> key not in excludeProviders)
.sort(([_, a], [__, b]) -> a.prio - b.prio)
.forEach(([key, { label }]) -> sortedOptions[key] = label)
# Provider comes first: selecting it pre-fills the name below.
attributes.push(
{ name: 'provider', display: __('Type'), tag: 'select', options: sortedOptions, null: false, nulloption: true, value: providerKey, customsort: 'on' }
)
attributes.push(
{ name: 'name', display: __('Name'), tag: 'input', type: 'text', null: false, limit: 100, autocomplete: 'one-time-code' }
]
)
attributes
# The provider fields are built dynamically ('config.token', ...), so they are not part of
# App.AIProviderConnection.configure_attributes — App.Model#validate resolves the attributes
@@ -646,8 +655,8 @@ class ProviderConnectionCredentials extends App.ControllerModal
@buttonSubmit = if lastStep then __('Submit') else __('Next')
@buttonClass = if lastStep then 'btn--success' else 'btn--primary'
# Only when creating: an existing Zammad AI connection stays editable on SaaS.
excludeProviders = if !@id and App.Config.get('system_online_service') then ['zammad_ai'] else []
# Provider dropdown: hide Zammad AI from the list in SaaS.
excludeProviders = if App.Config.get('system_online_service') then ['zammad_ai'] else []
# Merge unsaved edits (captured on a provider change, and on the way back from the model step)
# over the persisted values, so switching the provider keeps them for all compatible fields.
@@ -680,6 +689,13 @@ class ProviderConnectionCredentials extends App.ControllerModal
@update()
@$('[name=name]').focus()
if @providerKey is 'zammad_ai' and App.Config.get('system_online_service')
$('<div />')
.addClass('alert alert--warning')
.attr('role', 'alert')
.text(App.i18n.translatePlain('The Zammad AI provider is managed by the platform and cannot be fully edited here.'))
.prependTo(@controller.form)
@controller.form
onSubmit: (e) =>

View File

@@ -40,6 +40,12 @@ class App.Controller extends Spine.Controller
oldLocation = history[history.length-2]
if oldLocation
window.history.replaceState(null, null, oldLocation)
else
# Direct entry, e. g. a bookmark: there is no previous route to fall back to, so
# replace the current entry instead of adding one. The hash still changes, so the
# target route is executed by the resulting hashchange event.
window.location.replace(location.replace(/^#*/, '#'))
return
super location
preventDefault: (e) ->

View File

@@ -19,7 +19,7 @@ class App.UiElement.sla_times
# apply hour picker
item.find('.js-timeConvertFrom').timepicker(
maxHours: 999
maxHours: 99999
)
# disable/enable rows

View File

@@ -866,7 +866,7 @@ class Router extends App.ControllerPermanent
split = "/query/#{params.query}"
id = Math.floor( Math.random() * 99999 )
@navigate "#ticket/create/id/#{id}#{split}"
@navigate "#ticket/create/id/#{id}#{split}", { hideCurrentLocationFromHistory: true }
return
# check authentication

View File

@@ -34,6 +34,7 @@ class Package extends App.ControllerSubContent
)
render: ->
actions_available = App.Config.get('package_actions_enabled')
for item in @packages
item.action = []
@@ -45,6 +46,7 @@ class Package extends App.ControllerSubContent
else if item.state == 'deactivate'
item.action = ['uninstall', 'activate']
item.action = ['uninstall']
continue if !actions_available
if @api_package_metas[item.name] && item.vendor is @api_package_metas[item.name].vendor
if @isNewerVersion(item.version, @api_package_metas[item.name].version)
item.action.unshift('update_api')
@@ -63,6 +65,7 @@ class Package extends App.ControllerSubContent
token_present: @token_present
api_package_metas: @api_package_metas
api_packages_installable: api_packages_installable
actions_available: actions_available
)
uninstall: (e) ->
@@ -207,4 +210,4 @@ class PackageSettingsModal extends App.ControllerModal
super
@parent.load()
App.Config.set('Packages', { prio: 3700, name: __('Packages'), parent: '#system', target: '#system/package', controller: Package, permission: (controller) -> App.Config.get('admin_packages') && controller.permissionCheck('admin.package') }, 'NavBarAdmin')
App.Config.set('Packages', { prio: 3700, name: __('Packages'), parent: '#system', target: '#system/package', controller: Package, permission: (controller) -> controller.permissionCheck('admin.package') }, 'NavBarAdmin')

View File

@@ -77,6 +77,19 @@ class App.FormHandlerCoreWorkflow
result.lastUpdatedAt = App[attribute.relation].lastUpdatedAt()
return result
# a removed field is not part of the form params, so a hidden field
# would lose its configured default when a workflow mutates its options.
# fall back to the default on create screens like the backend does
@restrictedDefaultValue: (ui, item, values) ->
return '' if !ui.screen?.match(/^create/)
return '' if item.default is undefined || item.default is null || item.default is ''
allowedValues = _.map(values, (value) -> value?.toString())
defaultValues = _.filter(_.flatten([item.default]), (value) -> _.contains(allowedValues, value?.toString()))
return '' if defaultValues.length is 0
return defaultValues if item.multiple
defaultValues[0]
# restricts the dropdown and tree select values of a form
@restrictValues: (classname, form, ui, attributes, params, data) ->
return if _.isEmpty(data.restrict_values)
@@ -143,7 +156,7 @@ class App.FormHandlerCoreWorkflow
item.default = obj[item.name]
item.newValue = obj[item.name]
else
item.newValue = ''
item.newValue = App.FormHandlerCoreWorkflow.restrictedDefaultValue(ui, item, values)
if attribute.relation
item.rejectNonExistentValues = true

View File

@@ -90,8 +90,8 @@ class Show extends App.ControllerModal
super
content: ->
request_content = App.Utils.text2html(JSON.stringify(@record.request.content, null, 2))
response_content = App.Utils.text2html(JSON.stringify(@record.response.content, null, 2))
request_content = App.Utils.text2html(@formatContent(@record.request.content))
response_content = App.Utils.text2html(@formatContent(@record.response.content))
# Special formatting for AI Provider + Webhook logs
if @record?.facility and @record?.facility in ['AI::Provider', 'webhook']
@@ -104,6 +104,13 @@ class Show extends App.ControllerModal
response_content: response_content
)
# Strings are the raw payload and already carry their line breaks. Some facilities store a
# structure instead, which is pretty-printed so it comes out line by line as well.
formatContent: (content) ->
return content if _.isString(content)
JSON.stringify(content, null, 2)
formatJsonData: (data) ->
try
[header, body] = data.split('\n\n')

View File

@@ -828,14 +828,14 @@
second = timeArray[2] ? timeArray[2].toString() : '';
// adaptive time parsing
if (hour.length > 4) {
if (hour.length > hour_max_length + 2) {
second = hour.slice(-2);
hour = hour.slice(0, -2);
}
if (hour.length > hour_max_length) {
minute = hour.slice(-hour_max_length);
hour = hour.slice(0, -hour_max_length);
minute = hour.slice(-2);
hour = hour.slice(0, -2);
}
if (minute.length > 2) {

View File

@@ -1,13 +1,16 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T( 'Package' ) %> <small><%- @T('Management') %></small></h1>
<h1><%- @T('Packages') %></h1>
</div>
<% if @actions_available: %>
<div class="page-header-meta">
<a data-type="settings" class="btn js-packageSettings" href="#"><%- @T('Configure') %></a>
</div>
<% end %>
</div>
<div class="page-content">
<% if @actions_available: %>
<p>
<%- @T('The installation of packages comes with security implications, because arbitrary code will be executed in the context of the Zammad application.') %>
<br>
@@ -45,6 +48,10 @@
<button class="align-right btn btn--primary js-submit" type="submit" disabled="disabled"><%- @T('Install Package') %></button>
</form>
</p>
<% else: %>
<p><%- @T('This page gives you an overview of the packages currently installed in your Zammad instance.') %></p>
<p><%- @T('In container-based environments, packages are managed as part of the deployment and cannot be installed or removed from here.') %></p>
<% end %>
<h2 class="package-table-heading"><%- @T('Installed packages') %></h2>
@@ -60,7 +67,9 @@
<th><%- @T('Version') %></th>
<th><%- @T('Vendor') %></th>
<th><%- @T('State') %></th>
<% if @actions_available: %>
<th class="package-action-header"><%- @T('Action') %></th>
<% end %>
</tr>
</thead>
<tbody>
@@ -71,6 +80,7 @@
<td><%= item.version %><% if _.contains(item.action, 'update_api'): %> <span class="badge badge--closed"<% if lastLog: %> title="<%= lastLog.content %> (<%- @Ttimestamp(lastLog.date) %>)"<% end %>><%- @T('Update to %s', meta.version) %></span><% end %></td>
<td><% if item.url: %><a href="<%= item.url %>" target="_blank"><% end %><%= item.vendor %><% if item.url: %></a><% end %></td>
<td><%- @T(item.state) %></td>
<% if @actions_available: %>
<td class="package-action-cell no-padding">
<div class="dropdown dropdown--actions">
<div class="btn btn--table btn--text btn--secondary js-action package-action-toggle" data-toggle="dropdown">
@@ -99,13 +109,14 @@
</ul>
</div>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% if @token_present: %>
<% if @actions_available && @token_present: %>
<h2 class="package-table-heading"><%- @T('Available packages') %></h2>
<% if _.isEmpty(@api_packages_installable): %>
<table class="table table--placeholder">

View File

@@ -55,14 +55,14 @@ class ChecklistItemsController < ApplicationController
def new_item_params
@new_item_params ||= begin
safe_params = params.permit(:text, :checklist_id, :ticket_id)
safe_params = params.permit(:text, :checked, :checklist_id, :ticket_id)
replace_ticket_param_with_checklist(safe_params)
end
end
def create_bulk_params
@create_bulk_params ||= begin
safe_params = params.permit(:checklist_id, :ticket_id, items: %i[text])
safe_params = params.permit(:checklist_id, :ticket_id, items: %i[text checked])
replace_ticket_param_with_checklist(safe_params)
end
end

View File

@@ -2,6 +2,7 @@
class PackagesController < ApplicationController
prepend_before_action :authenticate_and_authorize!
before_action :prevent_container_environment, only: %i[install uninstall install_api update_api]
# GET /api/v1/packages
def index
@@ -56,6 +57,12 @@ class PackagesController < ApplicationController
private
def prevent_container_environment
return if !Zammad::Deployment.container?
raise Exceptions::UnprocessableContent, __('Installing, updating or uninstalling packages is not possible in container environments.')
end
def api_packages
@api_packages ||= Package.api_packages_hash({ version_name: Package.api_version_name })
end

View File

@@ -372,8 +372,8 @@ class SessionsController < ApplicationController
# Announce Elasticsearch availability to the front end.
config['es_enabled'] = SearchIndexBackend.enabled?
# Hide the admin packages interface in environments like docker, where installed packages do not persist.
config['admin_packages'] = ENV['ZAMMAD_DOCKER'].blank?
# Hide the package actions in environments like docker, where installed packages do not persist.
config['package_actions_enabled'] = !Zammad::Deployment.container?
# remember if we can switch back to user
if session[:switched_from_user_id]

View File

@@ -128,6 +128,8 @@ class UsersController < ApplicationController
user.update!(clean_params)
end
Service::User::NotifyPasswordChange.with_current_user(current_user).execute(user: user)
if response_expand?
user = user.reload.attributes_with_association_names
user.delete('password')

View File

@@ -45,7 +45,7 @@ describe('useBetaUiFeedbackConsent', () => {
await wrapper.events.click(wrapper.getByRole('button', { name: 'Agree & join' }))
waitFor(() => {
await waitFor(() => {
expect(localStorage.getItem('beta-ui-feedback-consent')).toBe('true')
})
})

View File

@@ -4,12 +4,13 @@
<script setup lang="ts">
import { VueDatePicker, WeekStart } from '@vuepic/vue-datepicker'
import { isValid, format, formatISO, parse, parseISO } from 'date-fns'
import { isValid, format, parse, parseISO } from 'date-fns'
import { isEqual } from 'lodash-es'
import { computed, nextTick, toRef, watch, useTemplateRef } from 'vue'
import { IMask, useIMask } from 'vue-imask'
import useValue from '#shared/components/Form/composables/useValue.ts'
import { formatDateTimeValue } from '#shared/components/Form/fields/FieldDate/normalizeDateValue.ts'
import type { DateTimeContext } from '#shared/components/Form/fields/FieldDate/types.ts'
import { useDateFnsLocale } from '#shared/components/Form/fields/FieldDate/useDateFnsLocale.ts'
import { useDateTime } from '#shared/components/Form/fields/FieldDate/useDateTime.ts'
@@ -187,7 +188,7 @@ const parseValue = (value: string) => {
}
const formatValue = (value: Date) => {
if (valueFormat.value === 'iso') return formatISO(value)
if (valueFormat.value === 'iso') return formatDateTimeValue(value)
return format(value, valueFormat.value)
}

View File

@@ -1,6 +1,7 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import healDateRange from '#shared/components/Form/fields/FieldDate/healDateRange.ts'
import normalizeDateValue from '#shared/components/Form/fields/FieldDate/normalizeDateValue.ts'
import { dateFieldProps } from '#shared/components/Form/fields/FieldDate/types.ts'
import createInput from '#shared/form/core/createInput.ts'
import addLink from '#shared/form/features/addLink.ts'
@@ -9,7 +10,7 @@ import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
import FieldDateTimeInput from './FieldDateTimeInput.vue'
const dateFieldDefinition = createInput(FieldDateTimeInput, dateFieldProps, {
features: [addLink, formUpdaterTrigger(), healDateRange],
features: [addLink, formUpdaterTrigger(), normalizeDateValue, healDateRange],
})
export default [

View File

@@ -1,7 +1,7 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import type { AutoCompleteKnowledgeBaseAnswerOption } from './types.ts'

View File

@@ -7,7 +7,7 @@ import type {
CommonSelectOptionEmits,
CommonSelectOptionProps,
} from '#desktop/components/CommonSelect/types.ts'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import type { AutoCompleteKnowledgeBaseCategoryIconOption } from './types.ts'

View File

@@ -3,7 +3,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import type { AutoCompleteKnowledgeBaseCategoryIconOption } from './types.ts'

View File

@@ -11,7 +11,7 @@ import { i18n } from '#shared/i18n.ts'
import { useTransitionCollapse } from '#desktop/composables/useTransitionCollapse.ts'
import { useTransitionConfig } from '#desktop/composables/useTransitionConfig.ts'
import type { PermissionsChildOption, PermissionsProps } from './types.ts'
import type { PermissionsChildOption, PermissionsParentOption, PermissionsProps } from './types.ts'
const props = defineProps<{
context: PermissionsProps
@@ -67,6 +67,20 @@ const toggleCollapse = (value: string) => {
collapseLookup.value[value] = !collapseLookup.value[value]
}
// A parent whose toggle cannot be flipped has nothing else to offer on click, so the whole row
// stands in for the chevron for pointer users; the chevron remains the focusable keyboard control.
// The disabled switch lets the click through (pointer-events: none).
const isGroupToggledByRow = (option: PermissionsParentOption) =>
!!option.children &&
!valueLookup.value[option.value] &&
(props.context.disabled || !!option.disabled)
const toggleCollapseByRow = (option: PermissionsParentOption) => {
if (!isGroupToggledByRow(option)) return
toggleCollapse(option.value)
}
const { delegateFocus } = useDelegateFocus(
props.context.id,
`permissions_toggle_${props.context.id}_${props.context?.options && props.context?.options[0]?.value}`,
@@ -94,10 +108,13 @@ const { collapseEnter, collapseAfterEnter, collapseLeave } = useTransitionCollap
:key="`option-${option.value}`"
class="flex flex-col"
>
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus, vuejs-accessibility/click-events-have-key-events -->
<div
class="flex items-center gap-2.5 px-3 py-2.5"
:class="{ 'cursor-pointer': isGroupToggledByRow(option) }"
role="treeitem"
:aria-selected="valueLookup[option.value]"
@click="toggleCollapseByRow(option)"
>
<FormKit
:id="`permissions_toggle_${context.id}_${option.value}`"

View File

@@ -176,6 +176,26 @@ describe('Form - Field - Permissions', () => {
expect(toggleSwitch).toBeDisabled()
})
it('toggles the child options when a disabled parent option is clicked', async () => {
const wrapper = await renderPermissions()
expect(wrapper.queryByRole('group')).not.toBeInTheDocument()
const permissions = wrapper.getAllByRole('treeitem')
const [parentLabel] = getAllByTestId(permissions[1], 'common-label')
await wrapper.events.click(parentLabel)
const childGroup = wrapper.getByRole('group')
expect(getAllByRole(childGroup, 'treeitem')).toHaveLength(testOptions[1].children!.length)
await wrapper.events.click(parentLabel)
expect(childGroup).not.toBeVisible()
})
})
// Cover all use cases from the FormKit custom input checklist.

View File

@@ -86,7 +86,7 @@ describe('Form - Field - Rating', () => {
locale.localeData = { dir } as any
const previousValue = star === 1 ? '' : (star - 1).toString()
const newValue = (star - 1).toString()
const newValue = star.toString()
const view = renderRatingField({
value: previousValue,
@@ -102,7 +102,7 @@ describe('Form - Field - Rating', () => {
// Increase rating.
await view.events.type(input, `{Arrow${dir === EnumTextDirection.Ltr ? 'Right' : 'Left'}}`)
waitFor(() => {
await waitFor(() => {
expect(node.context?.value).toBe(newValue)
expect(starIcon).toHaveClasses(['text-black', 'dark:text-white', 'icon-star-fill'])
})
@@ -110,7 +110,7 @@ describe('Form - Field - Rating', () => {
// Decrease rating.
await view.events.type(input, `{Arrow${dir === EnumTextDirection.Ltr ? 'Left' : 'Right'}}`)
waitFor(() => {
await waitFor(() => {
expect(node.context?.value).toBe(previousValue)
expect(starIcon).toHaveClass('icon-star')
})
@@ -118,7 +118,7 @@ describe('Form - Field - Rating', () => {
it.each([1, 2, 3, 4, 5])('supports vertical keyboard control (%s)', async (star) => {
const previousValue = star === 1 ? '' : (star - 1).toString()
const newValue = (star - 1).toString()
const newValue = star.toString()
const view = renderRatingField({
value: previousValue,
@@ -134,7 +134,7 @@ describe('Form - Field - Rating', () => {
// Increase rating.
await view.events.type(input, '{ArrowUp}')
waitFor(() => {
await waitFor(() => {
expect(node.context?.value).toBe(newValue)
expect(starIcon).toHaveClasses(['text-black', 'dark:text-white', 'icon-star-fill'])
})
@@ -142,7 +142,7 @@ describe('Form - Field - Rating', () => {
// Decrease rating.
await view.events.type(input, '{ArrowDown}')
waitFor(() => {
await waitFor(() => {
expect(node.context?.value).toBe(previousValue)
expect(starIcon).toHaveClass('icon-star')
})
@@ -162,9 +162,9 @@ describe('Form - Field - Rating', () => {
await view.events.type(numberInput, value)
waitFor(() => {
await waitFor(() => {
expect(node.context?.value).toBe(value)
expect(numberInput).toHaveValue(value)
expect(numberInput).toHaveValue(star)
})
})
})

View File

@@ -13,7 +13,7 @@ import {
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 { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
import { knowledgeBaseBrowseRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import { tagSearchRoute } from '#desktop/entities/tags/utils/routeLocation.ts'

View File

@@ -4,7 +4,7 @@
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import type { KnowledgeBaseAnswerTranslationFragment } from '#shared/graphql/types.ts'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerAttributes from './KnowledgeBaseAnswerAttributes.vue'

View File

@@ -0,0 +1,67 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import type { Link } from '#shared/types/router.ts'
import { type Props as CommonPopoverProps } from '#desktop/components/CommonPopover/CommonPopover.vue'
import CommonPopoverWithTrigger from '#desktop/components/CommonPopover/CommonPopoverWithTrigger.vue'
import KnowledgeBaseAnswerPopoverContent from './KnowledgeBaseAnswerPopoverWithTrigger/KnowledgeBaseAnswerPopoverContent.vue'
export interface Props {
answerId: string
/** Locale of the translation being shown, passed through so the popover describes that one. */
locale?: string
triggerLink?: Link
popoverConfig?: Omit<CommonPopoverProps, 'owner'>
triggerClass?: string
triggerLinkClass?: string
triggerLinkActiveClass?: string
noFocusStyling?: boolean
noHoverStyling?: boolean
zIndex?: string
}
defineProps<Props>()
defineOptions({
inheritAttrs: false,
})
defineSlots<{
default(props: {
isOpen?: boolean | undefined
popoverId?: string
hasOpenViaLongClick?: boolean
}): never
}>()
</script>
<!--
Deliberately ungated, unlike the ticket sidebar's TicketKnowledgeBaseAnswer, which hides its
popover from agents without `knowledge_base.*`. There the list is a ticket-side suggestion; here
the caller only offers an answer the user is already permitted to see, and a customer looking at
a published answer should get its details too. The rows a user may not see are dropped by the
server: `internalAt` and `archivedAt` are scoped fields on KnowledgeBaseAnswer, so they arrive
null and the attribute grid leaves their rows out.
-->
<template>
<CommonPopoverWithTrigger
:class="triggerClass ?? ''"
:no-hover-styling="noHoverStyling"
:no-focus-styling="noFocusStyling"
:z-index="zIndex"
:trigger-link="triggerLink"
:trigger-link-class="triggerLinkClass"
:trigger-link-active-class="triggerLinkActiveClass"
v-bind="{ ...popoverConfig, ...$attrs }"
>
<template #popover-content="{ popoverId }">
<KnowledgeBaseAnswerPopoverContent :id="popoverId" :answer-id="answerId" :locale="locale" />
</template>
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</CommonPopoverWithTrigger>
</template>

View File

@@ -0,0 +1,51 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
import KnowledgeBaseAnswerPopover from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswer/KnowledgeBaseAnswerPopover.vue'
import { useKnowledgeBaseAnswerInfoForPopoverQuery } from './graphql/queries/knowledgeBaseAnswerInfoForPopover.api.ts'
import KnowledgeBaseAnswerPopoverSkeleton from './skeleton/KnowledgeBaseAnswerPopoverSkeleton.vue'
interface Props {
answerId: string
/** Locale of the translation the trigger is showing, so the popover describes that one. */
locale?: string
}
const props = defineProps<Props>()
// Fired on mount, and this component is only mounted while the popover is open (CommonPopover
// renders its slot behind `v-if`), which is what makes the loading lazy - a list of ten items
// fetches nothing until one of them is actually opened.
const answerQuery = new QueryHandler(
useKnowledgeBaseAnswerInfoForPopoverQuery(
() => ({ answerId: props.answerId, locale: props.locale }),
() => ({ enabled: !!props.answerId, fetchPolicy: 'cache-and-network' }),
),
)
const result = answerQuery.result()
const translation = computed(() => result.value?.knowledgeBaseAnswer.translation)
const loading = answerQuery.loadingWithoutCachedResult()
</script>
<!--
The wrapper is load-bearing: KnowledgeBaseAnswerPopoverWithTrigger passes the popover's `id` down
to this component, and the skeleton below is a conditional root (a fragment), which cannot
inherit an attribute. Both the skeleton and KnowledgeBaseAnswerPopover carry their own padding.
-->
<template>
<div>
<KnowledgeBaseAnswerPopoverSkeleton :loading="loading">
<!-- Null only for an answer with no translation at all, which the list it was opened from
cannot have produced - guarded rather than asserted all the same. -->
<KnowledgeBaseAnswerPopover v-if="translation" :translation="translation" />
</KnowledgeBaseAnswerPopoverSkeleton>
</div>
</template>

View File

@@ -0,0 +1,25 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { KnowledgeBaseAnswerTranslationFragmentDoc } from '../../../../../../entities/knowledge-base/graphql/fragments/knowledgeBaseAnswerTranslation.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const KnowledgeBaseAnswerInfoForPopoverDocument = gql`
query knowledgeBaseAnswerInfoForPopover($answerId: ID!, $locale: String) {
knowledgeBaseAnswer(answerId: $answerId, locale: $locale) {
id
translation(locale: $locale) {
...knowledgeBaseAnswerTranslation
}
}
}
${KnowledgeBaseAnswerTranslationFragmentDoc}`;
export function useKnowledgeBaseAnswerInfoForPopoverQuery(variables: Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables | VueCompositionApi.Ref<Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables> | ReactiveFunction<Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>(KnowledgeBaseAnswerInfoForPopoverDocument, variables, options);
}
export function useKnowledgeBaseAnswerInfoForPopoverLazyQuery(variables?: Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables | VueCompositionApi.Ref<Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables> | ReactiveFunction<Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>(KnowledgeBaseAnswerInfoForPopoverDocument, variables, options);
}
export type KnowledgeBaseAnswerInfoForPopoverQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>;

View File

@@ -0,0 +1,22 @@
# Deliberately much lighter than `knowledgeBaseAnswer`, which selects
# `knowledgeBaseAnswerAttributes` - the full body, the navigation, the attachments, the breadcrumb
# and the policy. A popover shows a title, an excerpt and a handful of attributes, and it is opened
# from a list of ten, so it asks for the translation fragment alone.
#
# `locale` is the locale of the translation the caller is showing, so the popover describes that
# translation rather than the reader's preferred one - they differ when a search hit came from
# another locale.
#
# Passed twice on purpose. The query-level argument is what the server resolves titles in; the one
# on `translation` is what keys the field in the Apollo cache, so an answer opened in two locales
# holds one translation per locale rather than whichever arrived last (see
# Gql::Types::KnowledgeBase::AnswerType#translation and knowledgeBaseAnswerAttributes, which say the
# same).
query knowledgeBaseAnswerInfoForPopover($answerId: ID!, $locale: String) {
knowledgeBaseAnswer(answerId: $answerId, locale: $locale) {
id
translation(locale: $locale) {
...knowledgeBaseAnswerTranslation
}
}
}

View File

@@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './knowledgeBaseAnswerInfoForPopover.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockKnowledgeBaseAnswerInfoForPopoverQuery(defaults: Mocks.MockDefaultsValue<Types.KnowledgeBaseAnswerInfoForPopoverQuery, Types.KnowledgeBaseAnswerInfoForPopoverQueryVariables>) {
return Mocks.mockGraphQLResult(Operations.KnowledgeBaseAnswerInfoForPopoverDocument, defaults)
}
export function waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls() {
return Mocks.waitForGraphQLMockCalls<Types.KnowledgeBaseAnswerInfoForPopoverQuery>(Operations.KnowledgeBaseAnswerInfoForPopoverDocument)
}
export function mockKnowledgeBaseAnswerInfoForPopoverQueryError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.KnowledgeBaseAnswerInfoForPopoverDocument, message, extensions);
}

View File

@@ -0,0 +1,51 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import { useDebouncedLoading } from '#shared/composables/useDebouncedLoading.ts'
import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
interface Props {
loading?: boolean
}
const props = defineProps<Props>()
// Debounced like the other popover skeletons: a cached or fast answer would otherwise flash a
// skeleton for a frame on the way to content that was already there.
const { debouncedLoading } = useDebouncedLoading({
isLoading: computed(() => props.loading ?? false),
})
</script>
<template>
<!-- The padding is the skeleton's own rather than something the caller passes in: this root is
conditional, so the component renders a fragment and cannot inherit a class. Once loaded,
KnowledgeBaseAnswerPopover brings its own. (TicketPopoverSkeleton has no padding of its own
because TicketPopover puts it on the section that wraps both states - a variation on the same
pattern, not a different one.) -->
<div v-if="loading || debouncedLoading" class="p-3" :class="{ invisible: !debouncedLoading }">
<!-- Visibility icon and title. -->
<div class="mb-3 flex items-center gap-1.25">
<CommonSkeleton :style="{ 'animation-delay': `${0.1}s` }" class="h-4 w-4 shrink-0" rounded />
<CommonSkeleton :style="{ 'animation-delay': `${0.1}s` }" class="h-5 w-2/3" />
</div>
<!-- Body excerpt. -->
<div class="mb-3 space-y-2">
<CommonSkeleton :style="{ 'animation-delay': `${0.2}s` }" class="h-4 w-full" />
<CommonSkeleton :style="{ 'animation-delay': `${0.3}s` }" class="h-4 w-4/5" />
</div>
<!-- The attribute grid: a label and a value per row. -->
<div class="space-y-2">
<CommonSkeleton :style="{ 'animation-delay': `${0.4}s` }" class="h-4 w-1/4" />
<CommonSkeleton :style="{ 'animation-delay': `${0.5}s` }" class="h-5 w-1/3" />
<CommonSkeleton :style="{ 'animation-delay': `${0.6}s` }" class="h-4 w-1/4" />
<CommonSkeleton :style="{ 'animation-delay': `${0.7}s` }" class="h-5 w-1/5" />
</div>
</div>
<slot v-else />
</template>

View File

@@ -0,0 +1,25 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import KnowledgeBaseAnswerPopoverSkeleton from '../KnowledgeBaseAnswerPopoverSkeleton.vue'
describe('KnowledgeBaseAnswerPopoverSkeleton', () => {
it('renders a placeholder per row while loading', () => {
const wrapper = renderComponent(KnowledgeBaseAnswerPopoverSkeleton, {
props: { loading: true },
})
expect(wrapper.getAllByRole('progressbar')).toHaveLength(8)
})
it('renders its content once loading is done', () => {
const wrapper = renderComponent(KnowledgeBaseAnswerPopoverSkeleton, {
props: { loading: false },
slots: { default: 'Ocarina tuning' },
})
expect(wrapper.queryAllByRole('progressbar')).toHaveLength(0)
expect(wrapper.getByText('Ocarina tuning')).toBeInTheDocument()
})
})

View File

@@ -11,7 +11,7 @@ import {
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import type { DeepPartial } from '#shared/types/utils.ts'
import TicketKnowledgeBaseAnswerPopover from '../TicketKnowledgeBaseAnswerPopover.vue'
import KnowledgeBaseAnswerPopover from '../KnowledgeBaseAnswerPopover.vue'
const CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 42)
const LOCALE = 'en-gb'
@@ -57,7 +57,7 @@ const buildTranslation = (
)
const renderPopover = (translation = buildTranslation()) =>
renderComponent(TicketKnowledgeBaseAnswerPopover, {
renderComponent(KnowledgeBaseAnswerPopover, {
props: { translation },
router: true,
routerRoutes: [
@@ -73,7 +73,7 @@ const renderPopover = (translation = buildTranslation()) =>
store: true,
})
describe('TicketKnowledgeBaseAnswerPopover', () => {
describe('KnowledgeBaseAnswerPopover', () => {
it('shows the answer title and its content excerpt', () => {
const wrapper = renderPopover()

View File

@@ -0,0 +1,185 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { within } from '@testing-library/vue'
import { getGraphQLMockCalls } from '#tests/graphql/builders/mocks.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { KnowledgeBaseAnswerInfoForPopoverDocument } from '../KnowledgeBaseAnswerPopoverWithTrigger/graphql/queries/knowledgeBaseAnswerInfoForPopover.api.ts'
import {
mockKnowledgeBaseAnswerInfoForPopoverQuery,
waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls,
} from '../KnowledgeBaseAnswerPopoverWithTrigger/graphql/queries/knowledgeBaseAnswerInfoForPopover.mocks.ts'
import KnowledgeBaseAnswerPopoverWithTrigger from '../KnowledgeBaseAnswerPopoverWithTrigger.vue'
const ANSWER_ID = convertToGraphQLId('KnowledgeBase::Answer', 1)
const CATEGORY_ID = convertToGraphQLId('KnowledgeBase::Category', 42)
const LOCALE = 'en-gb'
const mockAnswer = () =>
mockKnowledgeBaseAnswerInfoForPopoverQuery({
knowledgeBaseAnswer: {
id: ANSWER_ID,
translation: {
id: convertToGraphQLId('KnowledgeBase::Answer::Translation', 1),
title: 'Ocarina tuning',
visibility: EnumKnowledgeBaseVisibility.Published,
categoryTreeTranslation: [
{
id: convertToGraphQLId('KnowledgeBase::Category::Translation', 42),
title: 'Instruments',
},
],
content: { bodyExcerpt: 'Hold the ocarina with both hands.' },
answer: {
id: ANSWER_ID,
archivedAt: null,
publishedAt: '2024-01-01T00:00:00Z',
internalAt: null,
tags: null,
category: { id: CATEGORY_ID },
},
kbLocale: {
systemLocale: { locale: LOCALE, name: 'English (GB)' },
},
},
},
})
const renderTrigger = () =>
renderComponent(KnowledgeBaseAnswerPopoverWithTrigger, {
props: {
answerId: ANSWER_ID,
locale: LOCALE,
triggerLink: '/help/en-gb/42/1',
},
slots: { default: 'Ocarina tuning' },
router: true,
routerRoutes: [
{ path: '/', name: 'Root', component: { template: '<div />' } },
{
path: '/knowledge-base/:localeCode/category/:categoryInternalId',
name: 'KnowledgeBaseCategory',
component: { template: '<div />' },
},
// Tags link into the detailed search.
{ path: '/search/:searchTerm?', name: 'Search', component: { template: '<div />' } },
{ path: '/:pathMatch(.*)*', name: 'Error', component: { template: '<div />' } },
],
store: true,
})
describe('KnowledgeBaseAnswerPopoverWithTrigger', () => {
beforeEach(() => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
})
// The point of the whole wrapper: the quicksearch group renders ten of these and must not fetch
// a popover's worth of data for any of them until one is opened.
it('does not fetch the answer before the popover is opened', () => {
mockAnswer()
const wrapper = renderTrigger()
expect(wrapper.getByRole('link', { name: 'Ocarina tuning' })).toBeInTheDocument()
expect(getGraphQLMockCalls(KnowledgeBaseAnswerInfoForPopoverDocument)).toHaveLength(0)
})
it('fetches the answer when the popover opens', async () => {
mockAnswer()
const wrapper = renderTrigger()
await wrapper.events.hover(wrapper.getByRole('link'))
const calls = await waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls()
expect(calls).toHaveLength(1)
})
// The item that opened the popover names a locale, and a search hit can come from a locale other
// than the reader's preferred one - so the popover has to describe the translation it was
// opened from rather than resolving its own.
it('asks for the translation in the locale it was given', async () => {
mockAnswer()
const wrapper = renderTrigger()
await wrapper.events.hover(wrapper.getByRole('link'))
const calls = await waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls()
expect(calls.at(-1)?.variables).toEqual({ answerId: ANSWER_ID, locale: LOCALE })
})
// Both halves of the lazy load in one example, because the second only means something after the
// first: the popover opens on a skeleton and swaps it for the answer when the query answers.
it('shows a skeleton until the answer arrives, then the details', async () => {
mockAnswer()
const wrapper = renderTrigger()
await wrapper.events.hover(wrapper.getByRole('link'))
const popover = await wrapper.findByRole('region')
expect(within(popover).getAllByRole('progressbar').length).toBeGreaterThan(0)
expect(await within(popover).findByText('Ocarina tuning')).toBeVisible()
expect(within(popover).queryAllByRole('progressbar')).toHaveLength(0)
expect(within(popover).getByText('Hold the ocarina with both hands.')).toBeVisible()
expect(within(popover).getByText('Language')).toBeVisible()
expect(within(popover).getByRole('link', { name: 'Instruments' })).toBeVisible()
})
// Deliberately not gated on `knowledge_base.*`, unlike the ticket sidebar's answer list: here the
// caller only offers answers the user may already see. What a customer may not see is dropped
// by the server (`internalAt` and `archivedAt` are scoped fields), not by hiding the popover -
// which would leave them with a bare title.
it('shows the details to a user without any knowledge base permission', async () => {
mockPermissions(['ticket.customer'])
mockKnowledgeBaseAnswerInfoForPopoverQuery({
knowledgeBaseAnswer: {
id: ANSWER_ID,
translation: {
title: 'Ocarina tuning',
visibility: EnumKnowledgeBaseVisibility.Published,
categoryTreeTranslation: [
{
id: convertToGraphQLId('KnowledgeBase::Category::Translation', 42),
title: 'Instruments',
},
],
content: { bodyExcerpt: 'Hold the ocarina with both hands.' },
answer: {
id: ANSWER_ID,
// What the server hands a customer: the editorial dates are denied, publication is not.
archivedAt: null,
internalAt: null,
publishedAt: '2024-01-01T00:00:00Z',
tags: null,
category: { id: CATEGORY_ID },
},
kbLocale: { systemLocale: { locale: LOCALE, name: 'English (GB)' } },
},
},
})
const wrapper = renderTrigger()
await wrapper.events.hover(wrapper.getByRole('link'))
const popover = await wrapper.findByRole('region')
expect(await within(popover).findByText('Ocarina tuning')).toBeVisible()
expect(within(popover).getByText('Published')).toBeVisible()
expect(within(popover).getByText('Language')).toBeVisible()
expect(within(popover).queryByText('Internally published')).not.toBeInTheDocument()
expect(within(popover).queryByText('Archived')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,114 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { KnowledgeBaseAnswerTranslation } from '#shared/graphql/types.ts'
import type { ObjectWithId } from '#shared/types/utils.ts'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import CommonAdvancedTable from '#desktop/components/CommonTable/CommonAdvancedTable.vue'
import CommonTableSkeleton from '#desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { getKnowledgeBaseAnswerLink } from '#desktop/entities/knowledge-base/utils/knowledgeBaseAnswerLink.ts'
import { useListTable } from '../CommonTable/composables/useListTable.ts'
import type { ListTableEmits, ListTableProps, TableAttribute } from '../CommonTable/types.ts'
const props = defineProps<ListTableProps<KnowledgeBaseAnswerTranslation>>()
const emit = defineEmits<ListTableEmits>()
const router = useRouter()
const getLink = (item: ObjectWithId) => {
const link = getKnowledgeBaseAnswerLink(item as KnowledgeBaseAnswerTranslation)
return typeof link === 'string' ? link : router.resolve(link).fullPath
}
const { goToItem, goToItemLinkColumn, loadMore, resort, storageKeyId } = useListTable(
props,
emit,
getLink,
)
const tableAttributes: TableAttribute[] = [
{
name: 'title',
label: __('Name'),
headerPreferences: { noSorting: true },
columnPreferences: {
link: goToItemLinkColumn,
tooltip: (item) => (item as KnowledgeBaseAnswerTranslation).title,
},
dataType: 'input',
},
{
name: 'updated_at',
label: __('Updated at'),
headerPreferences: { noSorting: true },
columnPreferences: { alignContent: 'left' },
dataType: 'datetime',
},
{
name: 'visibility',
label: __('Visibility'),
headerPreferences: { noSorting: true },
columnPreferences: {},
dataType: 'icon',
},
]
</script>
<template>
<CommonLoader :loading="loading">
<template #skeleton>
<CommonTableSkeleton
:columns="headers.length"
:load-more="loadingNewPage"
:rows="skeletonLoadingCount"
/>
</template>
<slot v-if="!loading && !items.length" name="empty-list" />
<div v-else-if="items.length">
<CommonAdvancedTable
:caption="caption"
:headers="headers"
:order-by="orderBy"
:order-direction="orderDirection"
:group-by="groupBy"
:reached-scroll-top="reachedScrollTop"
:scroll-container="scrollContainer"
:attributes="tableAttributes"
:items="items"
:total-items-count="totalCount"
:storage-key-id="storageKeyId"
:max-items="maxItems"
:is-sorting="resorting"
@load-more="loadMore"
@click-row="goToItem"
@sort="resort"
>
<template #column-cell-visibility="{ item, isRowSelected }">
<div class="flex min-w-0 items-center gap-1.5">
<KnowledgeBaseAnswerIcon
:visibility="(item as KnowledgeBaseAnswerTranslation).visibility"
size="tiny"
/>
<CommonLabel
class="truncate text-gray-100! group-hover:text-black! group-active:text-white! dark:text-neutral-400! group-hover:dark:text-white!"
:class="{ 'text-black! dark:text-white!': isRowSelected }"
>
{{ $t(visibilityMeta[(item as KnowledgeBaseAnswerTranslation).visibility].label) }}
</CommonLabel>
</div>
</template>
</CommonAdvancedTable>
</div>
</CommonLoader>
</template>

View File

@@ -0,0 +1,130 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import '#tests/graphql/builders/mocks.ts'
import { renderComponent } from '#tests/support/components/index.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { mockRouterHooks } from '#tests/support/mock-vue-router.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import {
EnumKnowledgeBaseVisibility,
type KnowledgeBaseAnswerTranslation,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import type { ListTableProps } from '#desktop/components/CommonTable/types.ts'
import KnowledgeBaseAnswerTable from '../KnowledgeBaseAnswerTable.vue'
mockRouterHooks()
// The row link resolves the named answer route, so the test router has to know it. The catch-all
// stands in for the public help site, where an answer goes when the user cannot browse in-app.
const routerRoutes = [
{ path: '/', name: 'Dashboard', component: { template: '<div />' } },
{
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId',
name: 'KnowledgeBaseAnswer',
component: { template: '<div />' },
},
{ path: '/:pathMatch(.*)*', name: 'Error', component: { template: '<div />' } },
]
const tableHeaders = ['title', 'updated_at', 'visibility']
// The shape the detailed search selects, not the full type — `detailSearch.graphql` asks for these
// fields alone, so a complete record would test something the component never receives.
const tableItems = [
{
__typename: 'KnowledgeBaseAnswerTranslation',
id: convertToGraphQLId('KnowledgeBase::Answer::Translation', 7),
title: 'Ocarina tuning guide',
updatedAt: '2025-02-20T10:21:14Z',
visibility: EnumKnowledgeBaseVisibility.Published,
answer: {
__typename: 'KnowledgeBaseAnswer',
id: convertToGraphQLId('KnowledgeBase::Answer', 42),
category: {
__typename: 'KnowledgeBaseCategory',
id: convertToGraphQLId('KnowledgeBase::Category', 3),
},
},
kbLocale: {
__typename: 'KnowledgeBaseLocale',
systemLocale: { __typename: 'Locale', locale: 'en-us' },
},
},
] as KnowledgeBaseAnswerTranslation[]
const renderListTable = async (props: Partial<ListTableProps<KnowledgeBaseAnswerTranslation>>) => {
const wrapper = renderComponent(KnowledgeBaseAnswerTable, {
router: true,
routerRoutes,
form: true,
props: {
headers: tableHeaders,
items: tableItems,
totalCount: 1,
caption: 'Search result for: Knowledge base answer',
tableId: 'search-knowledge-base-answer-table',
maxItems: 1000,
loading: false,
loadingNewPage: false,
...props,
},
})
await waitForNextTick()
return wrapper
}
describe('KnowledgeBaseAnswerTable', () => {
beforeEach(() => {
mockPermissions(['knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
})
it('renders the three columns the story asks for', async () => {
const wrapper = await renderListTable({})
expect(wrapper.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument()
expect(wrapper.getByRole('columnheader', { name: 'Updated at' })).toBeInTheDocument()
expect(wrapper.getByRole('columnheader', { name: 'Visibility' })).toBeInTheDocument()
})
it('renders the answer title and its visibility', async () => {
const wrapper = await renderListTable({})
expect(wrapper.getByText('Ocarina tuning guide')).toBeInTheDocument()
expect(wrapper.getAllByText('Published').length).toBeGreaterThan(0)
})
// The locale comes from the row, not from the session: a result may be in a locale other than
// the user's own, and the answer route needs both it and the answer's internal id. The
// `/desktop` prefix is the app's mount point, which CommonLink puts back on.
it('links a row to the answer in its own locale', async () => {
const wrapper = await renderListTable({})
expect(wrapper.getByRole('link', { name: 'Ocarina tuning guide' })).toHaveAttribute(
'href',
'/desktop/knowledge-base/locale/en-us/answer/42',
)
})
// No columns are sortable: the knowledge base backend ranks by relevance and the search query
// ignores `orderBy` for this entity, so a sort control would do nothing.
it('offers no sorting on any column', async () => {
const wrapper = await renderListTable({})
expect(wrapper.queryAllByRole('button', { name: /^Sort by/ })).toHaveLength(0)
})
it('renders the empty-list slot when there are no answers', async () => {
const wrapper = await renderListTable({ items: [], totalCount: 0 })
expect(wrapper.queryByRole('table')).not.toBeInTheDocument()
})
})

View File

@@ -17,6 +17,12 @@ import { useQuickSearchInput } from '../useQuickSearchInput.ts'
const RESULT_LIMIT = 10
// A search matching three or four entity types turns the panel into a stack of long lists and
// stops being scannable, so from that point on every group is cut down to a shorter list. Both
// limits and the threshold are fixed in code on purpose - there is no admin setting for them.
const REDUCED_RESULT_LIMIT = 5
const REDUCED_RESULT_LIMIT_TYPE_THRESHOLD = 2
interface Props {
search: string
debounceTime: number
@@ -57,30 +63,43 @@ whenever(
{ once: true, immediate: true },
)
const mappedQuickSearchResults = computed(() => {
const mappedQuickSearchResults = computed<QuickSearchResultData[] | undefined>(() => {
const currentResult = quickSearchResult.value
if (!currentResult) return
const searchResults: QuickSearchResultData[] = []
sortedByPriorityPlugins.value.forEach((plugin) => {
if (!currentResult[plugin.quickSearchResultKey]) return
const matchedPlugins = sortedByPriorityPlugins.value.flatMap((plugin) => {
const searchResult = currentResult[plugin.quickSearchResultKey]
if (!searchResult || searchResult.totalCount === 0) return
searchResults.push({
name: plugin.name,
component: plugin.quickSearchComponent,
items: searchResult.items,
label: plugin.quickSearchResultLabel,
remainingItemCount: searchResult.totalCount - searchResult.items.length,
totalCount: searchResult.totalCount,
})
if (!searchResult || searchResult.totalCount === 0) return []
return [{ plugin, searchResult }]
})
return searchResults
// The threshold counts the entity types the current user is shown, which is why the groups have
// to be collected before it can be applied: a plugin left out by its permissions or its
// `show()` - knowledge base answers without an active knowledge base - can still carry results
// from the backend, and shortening the visible groups for a group nobody sees would be wrong.
const itemLimit =
matchedPlugins.length > REDUCED_RESULT_LIMIT_TYPE_THRESHOLD
? REDUCED_RESULT_LIMIT
: RESULT_LIMIT
return matchedPlugins.map(({ plugin, searchResult }) => {
const items = searchResult.items.slice(0, itemLimit)
return {
name: plugin.name,
component: plugin.quickSearchComponent,
items,
label: plugin.quickSearchResultLabel,
// Counted against what the group displays, not against what the query returned - under the
// reduced limit the group withholds items it already has in hand, and they belong in the
// count the "%s more" link shows.
remainingItemCount: searchResult.totalCount - items.length,
totalCount: searchResult.totalCount,
}
})
})
const isLoadingSearchResults = quickSearchQuery.loadingWithoutCachedResult()

View File

@@ -9,7 +9,11 @@ import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import { EnumTicketStateColorCode } from '#shared/graphql/types.ts'
import {
EnumKnowledgeBaseVisibility,
EnumSearchableModels,
EnumTicketStateColorCode,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import {
@@ -18,6 +22,21 @@ import {
} from '../../../graphql/queries/quickSearch.mocks.ts'
import QuickSearchResultList from '../QuickSearchResultList.vue'
// The test router is created once per file and these replace its defaults wholesale, so every
// route any result item links to has to be here. The knowledge base answer route is what an
// answer item resolves to for a user who may open the agent answer view; the catch-all is where
// the public help-site path everyone else gets resolves to, as it does in the real router.
const routerRoutes = [
{ path: '/', name: 'Dashboard', component: { template: '<div />' } },
{ path: '/search/:searchTerm?', name: 'Search', component: { template: '<div />' } },
{
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId',
name: 'KnowledgeBaseAnswer',
component: { template: '<div />' },
},
{ path: '/:pathMatch(.*)*', name: 'Error', component: { template: '<div />' } },
]
const renderQuickSearchResultList = async (search: string) => {
const wrapper = renderComponent(QuickSearchResultList, {
props: {
@@ -25,6 +44,7 @@ const renderQuickSearchResultList = async (search: string) => {
debounceTime: 400,
},
router: true,
routerRoutes,
})
await waitForNextTick()
@@ -32,6 +52,18 @@ const renderQuickSearchResultList = async (search: string) => {
return wrapper
}
const answerTranslation = (id: number, title: string) => ({
__typename: 'KnowledgeBaseAnswerTranslation' as const,
id: convertToGraphQLId('KnowledgeBase::Answer::Translation', id),
title,
visibility: EnumKnowledgeBaseVisibility.Published,
kbLocale: { systemLocale: { locale: 'en-us' } },
answer: {
id: convertToGraphQLId('KnowledgeBase::Answer', id),
category: { id: convertToGraphQLId('KnowledgeBase::Category', 1) },
},
})
describe('QuickSearchResultList', () => {
it('renders by default the sections with an empty state', async () => {
mockQuickSearchQuery({
@@ -335,3 +367,291 @@ describe('QuickSearchResultList', () => {
expect(wrapper.queryByText('Found organizations')).not.toBeInTheDocument()
})
})
// The fourth result group, and the first one gated by `show` rather than by `permissions` - see
// plugins/knowledgeBaseAnswer.ts for why it has to be.
describe('QuickSearchResultList knowledge base answers', () => {
const mockAnswers = (totalCount: number, ...titles: string[]) => {
mockQuickSearchQuery({
quickSearchOrganizations: { totalCount: 0, items: [] },
quickSearchUsers: { totalCount: 0, items: [] },
quickSearchTickets: { totalCount: 0, items: [] },
quickSearchKnowledgeBaseAnswers: {
totalCount,
items: titles.map((title, index) => answerTranslation(index + 1, title)),
},
})
}
it('renders the group for an agent who may browse the knowledge base', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockAnswers(1, 'Ocarina tuning')
const wrapper = await renderQuickSearchResultList('ocarina')
await waitForQuickSearchQueryCalls()
expect(wrapper.getByText('Found knowledge base answers')).toBeInTheDocument()
expect(wrapper.getByText('Ocarina tuning')).toBeInTheDocument()
})
// A customer holds no knowledge base permission at all, which is exactly why the plugin cannot be
// gated by `permissions` - AC5 of the story still promises them published answers.
it('renders the group for a customer when the knowledge base is public', async () => {
mockPermissions(['ticket.customer'])
mockApplicationConfig({ kb_active: true, kb_active_publicly: true })
mockAnswers(1, 'Ocarina tuning')
const wrapper = await renderQuickSearchResultList('ocarina')
await waitForQuickSearchQueryCalls()
expect(wrapper.getByText('Found knowledge base answers')).toBeInTheDocument()
})
it('omits the group when no answer matches', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockAnswers(0)
const wrapper = await renderQuickSearchResultList('sackbut')
await waitForQuickSearchQueryCalls()
expect(wrapper.queryByText('Found knowledge base answers')).not.toBeInTheDocument()
})
it('omits the group when no knowledge base is enabled', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
mockAnswers(1, 'Ocarina tuning')
const wrapper = await renderQuickSearchResultList('ocarina')
await waitForQuickSearchQueryCalls()
expect(wrapper.queryByText('Found knowledge base answers')).not.toBeInTheDocument()
})
// AC9's link to the detailed search for the rest.
it('links to the detailed search for the answers it does not show', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockAnswers(12, 'Ocarina tuning', 'Ocarina cleaning')
const wrapper = await renderQuickSearchResultList('ocarina')
await waitForQuickSearchQueryCalls()
await wrapper.events.click(wrapper.getByRole('link', { name: '10 more' }))
const router = getTestRouter()
await waitFor(() => expect(router.currentRoute.value.name).toBe('Search'))
expect(router.currentRoute.value.params).toEqual({ searchTerm: 'ocarina' })
expect(router.currentRoute.value.query).toEqual({
entity: EnumSearchableModels.KnowledgeBaseAnswerTranslation,
})
})
// The same for one of the groups that had a tab all along.
it('keeps the entity on the link of a group that has a detailed-search tab', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockQuickSearchQuery({
quickSearchOrganizations: { totalCount: 0, items: [] },
quickSearchUsers: { totalCount: 0, items: [] },
quickSearchKnowledgeBaseAnswers: { totalCount: 0, items: [] },
quickSearchTickets: {
totalCount: 12,
items: [
{
__typename: 'Ticket',
id: convertToGraphQLId('Ticket', 1),
internalId: 1,
title: 'Ocarina ticket',
number: '123',
state: { id: convertToGraphQLId('Ticket::State', 1), name: 'open' },
stateColorCode: EnumTicketStateColorCode.Open,
},
],
},
})
const wrapper = await renderQuickSearchResultList('ocarina')
await waitForQuickSearchQueryCalls()
await wrapper.events.click(wrapper.getByRole('link', { name: '11 more' }))
const router = getTestRouter()
await waitFor(() => expect(router.currentRoute.value.name).toBe('Search'))
expect(router.currentRoute.value.query).toEqual({ entity: 'Ticket' })
})
})
// The panel shows at most 10 items per group, but drops to 5 as soon as three or more entity types
// match at once - see zammad/coordination-desktop-view#901.
describe('QuickSearchResultList per entity type limit', () => {
const tickets = (count: number) =>
Array.from({ length: count }, (_, index) => ({
__typename: 'Ticket' as const,
id: convertToGraphQLId('Ticket', index + 1),
internalId: index + 1,
title: `Ticket ${index + 1}`,
number: `${index + 1}`,
state: { id: convertToGraphQLId('Ticket::State', 1), name: 'open' },
stateColorCode: EnumTicketStateColorCode.Open,
}))
const users = (count: number) =>
Array.from({ length: count }, (_, index) => ({
__typename: 'User' as const,
id: convertToGraphQLId('User', index + 1),
internalId: index + 1,
fullname: `User ${index + 1}`,
}))
const organizations = (count: number) =>
Array.from({ length: count }, (_, index) => ({
__typename: 'Organization' as const,
id: convertToGraphQLId('Organization', index + 1),
internalId: index + 1,
name: `Organization ${index + 1}`,
}))
const answers = (count: number) =>
Array.from({ length: count }, (_, index) => answerTranslation(index + 1, `Answer ${index + 1}`))
it('shows five results per group when three entity types match', async () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
mockQuickSearchQuery({
quickSearchTickets: { totalCount: 10, items: tickets(10) },
quickSearchUsers: { totalCount: 10, items: users(10) },
quickSearchOrganizations: { totalCount: 10, items: organizations(10) },
quickSearchKnowledgeBaseAnswers: { totalCount: 0, items: [] },
})
const wrapper = await renderQuickSearchResultList('1')
await waitForQuickSearchQueryCalls()
expect(await wrapper.findByText('#5 - Ticket 5')).toBeInTheDocument()
expect(wrapper.queryByText('#6 - Ticket 6')).not.toBeInTheDocument()
expect(wrapper.getByText('User 5')).toBeInTheDocument()
expect(wrapper.queryByText('User 6')).not.toBeInTheDocument()
expect(wrapper.getByText('Organization 5')).toBeInTheDocument()
expect(wrapper.queryByText('Organization 6')).not.toBeInTheDocument()
})
it('shows five results per group when all four entity types match', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockQuickSearchQuery({
quickSearchTickets: { totalCount: 10, items: tickets(10) },
quickSearchUsers: { totalCount: 10, items: users(10) },
quickSearchOrganizations: { totalCount: 10, items: organizations(10) },
quickSearchKnowledgeBaseAnswers: { totalCount: 10, items: answers(10) },
})
const wrapper = await renderQuickSearchResultList('1')
await waitForQuickSearchQueryCalls()
expect(await wrapper.findByText('#5 - Ticket 5')).toBeInTheDocument()
expect(wrapper.queryByText('#6 - Ticket 6')).not.toBeInTheDocument()
expect(wrapper.getByText('Answer 5')).toBeInTheDocument()
expect(wrapper.queryByText('Answer 6')).not.toBeInTheDocument()
})
// Also the sub-bullet of the first criterion: the two groups the panel drops for having no result
// at all do not push it over the threshold.
it('shows ten results per group when two entity types match', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockQuickSearchQuery({
quickSearchTickets: { totalCount: 10, items: tickets(10) },
quickSearchUsers: { totalCount: 10, items: users(10) },
quickSearchOrganizations: { totalCount: 0, items: [] },
quickSearchKnowledgeBaseAnswers: { totalCount: 0, items: [] },
})
const wrapper = await renderQuickSearchResultList('1')
await waitForQuickSearchQueryCalls()
expect(await wrapper.findByText('#10 - Ticket 10')).toBeInTheDocument()
expect(wrapper.getByText('User 10')).toBeInTheDocument()
expect(wrapper.queryByText('Found organizations')).not.toBeInTheDocument()
expect(wrapper.queryByText('Found knowledge base answers')).not.toBeInTheDocument()
})
// A group the user is not shown can still carry results from the backend - cutting the two
// visible groups down for it would shorten the panel for a group nobody sees.
it('does not count a group hidden from the user towards the threshold', async () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
mockQuickSearchQuery({
quickSearchTickets: { totalCount: 10, items: tickets(10) },
quickSearchUsers: { totalCount: 10, items: users(10) },
quickSearchOrganizations: { totalCount: 0, items: [] },
quickSearchKnowledgeBaseAnswers: { totalCount: 10, items: answers(10) },
})
const wrapper = await renderQuickSearchResultList('1')
await waitForQuickSearchQueryCalls()
expect(wrapper.queryByText('Found knowledge base answers')).not.toBeInTheDocument()
expect(await wrapper.findByText('#10 - Ticket 10')).toBeInTheDocument()
expect(wrapper.getByText('User 10')).toBeInTheDocument()
})
// The reduced limit withholds items the query already returned, so the link has to count them
// too - against what the group displays, not against what came back.
it('counts the results the reduced limit withholds into the link to the detailed search', async () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
mockQuickSearchQuery({
quickSearchTickets: { totalCount: 8, items: tickets(8) },
quickSearchUsers: { totalCount: 5, items: users(5) },
quickSearchOrganizations: { totalCount: 6, items: organizations(6) },
quickSearchKnowledgeBaseAnswers: { totalCount: 0, items: [] },
})
const wrapper = await renderQuickSearchResultList('1')
await waitForQuickSearchQueryCalls()
expect(await wrapper.findByRole('link', { name: '3 more' })).toBeInTheDocument()
expect(wrapper.getByRole('link', { name: '1 more' })).toBeInTheDocument()
// The group whose five results the reduced limit takes in full keeps no link at all, so the two
// above are the only ones the panel shows.
expect(wrapper.getAllByRole('link', { name: /more$/ })).toHaveLength(2)
await wrapper.events.click(wrapper.getByRole('link', { name: '3 more' }))
const router = getTestRouter()
await waitFor(() => expect(router.currentRoute.value.name).toBe('Search'))
expect(router.currentRoute.value.params).toEqual({ searchTerm: '1' })
expect(router.currentRoute.value.query).toEqual({ entity: 'Ticket' })
})
})

View File

@@ -0,0 +1,48 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import type { KnowledgeBaseAnswerTranslation } from '#shared/graphql/types.ts'
import KnowledgeBaseAnswerPopoverWithTrigger from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswer/KnowledgeBaseAnswerPopoverWithTrigger.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { getKnowledgeBaseAnswerLink } from '#desktop/entities/knowledge-base/utils/knowledgeBaseAnswerLink.ts'
import type { QuickSearchPluginProps } from '../../types.ts'
const props = defineProps<QuickSearchPluginProps>()
// The searchable unit is the translation, not the answer - so that is what the group lists, and
// what carries the title and the locale the link and the popover are built from.
const translation = computed(() => props.item as KnowledgeBaseAnswerTranslation)
// Sends anyone who can browse a knowledge base to the answer view in Zammad - customers and agents
// without a knowledge base permission included, whenever it is publicly available. The public
// help site stays the fallback for an instance with no browsable knowledge base at all.
const link = computed(() => getKnowledgeBaseAnswerLink(translation.value))
// The popover loads by answer id rather than being handed this translation: the group's selection
// is deliberately small, and fetching the popover's fragment for ten items on every debounced
// keystroke to render something the user may never open is exactly what it avoids.
const answerId = computed(() => translation.value.answer.id)
const locale = computed(() => translation.value.kbLocale.systemLocale.locale)
</script>
<template>
<KnowledgeBaseAnswerPopoverWithTrigger
:answer-id="answerId"
:locale="locale"
:trigger-link="link"
:popover-config="{ orientation: 'right' }"
z-index="52"
class="group/item flex grow items-center gap-2 rounded-md px-2 py-3 text-neutral-400 hover:bg-blue-900 hover:no-underline!"
trigger-link-active-class="outline-2! outline-offset-1! outline-blue-800! hover:outline-blue-800!"
>
<KnowledgeBaseAnswerIcon show-tooltip :visibility="translation.visibility" size="tiny" />
<CommonLabel class="block! truncate group-hover/item:text-white">
{{ translation.title }}
</CommonLabel>
</KnowledgeBaseAnswerPopoverWithTrigger>
</template>

View File

@@ -0,0 +1,150 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import '#tests/graphql/builders/mocks.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswer/KnowledgeBaseAnswerPopoverWithTrigger/graphql/queries/knowledgeBaseAnswerInfoForPopover.mocks.ts'
import KnowledgeBaseAnswer from '../KnowledgeBaseAnswer.vue'
const LOCALE = 'en-us'
const answerTranslation = (
id: number,
title: string,
visibility = EnumKnowledgeBaseVisibility.Published,
) => ({
__typename: 'KnowledgeBaseAnswerTranslation' as const,
id: convertToGraphQLId('KnowledgeBase::Answer::Translation', id),
title,
visibility,
kbLocale: { systemLocale: { locale: LOCALE } },
answer: {
id: convertToGraphQLId('KnowledgeBase::Answer', id),
category: { id: convertToGraphQLId('KnowledgeBase::Category', 7) },
},
})
const renderAnswer = (item: ReturnType<typeof answerTranslation>) =>
renderComponent(KnowledgeBaseAnswer, {
props: {
item,
// The singular literal QuickSearchResultList actually passes, which does not match the
// `QuickSearchPluginProps` union's `'quick-search-results'`. Pinned so nothing here starts
// branching on a value that never arrives.
mode: 'quick-search-result',
},
router: true,
routerRoutes: [
{ path: '/', name: 'Dashboard', component: { template: '<div />' } },
{
path: '/knowledge-base/locale/:localeCode/answer/:answerInternalId',
name: 'KnowledgeBaseAnswer',
component: { template: '<div />' },
},
// The public answer page leaves the app, so it resolves to the catch-all here just as it
// does in the real router.
{ path: '/:pathMatch(.*)*', name: 'Error', component: { template: '<div />' } },
],
store: true,
})
describe('KnowledgeBaseAnswer quicksearch item', () => {
beforeEach(() => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
// The group is only ever shown while a knowledge base is browsable, and that is also what
// decides where an answer link points - see getKnowledgeBaseAnswerLink.
mockApplicationConfig({ kb_active: true })
})
it('renders the answer title', () => {
const wrapper = renderAnswer(answerTranslation(1, 'Ocarina tuning'))
expect(wrapper.getByText('Ocarina tuning')).toBeInTheDocument()
})
it('renders the publication state as an icon', () => {
const wrapper = renderAnswer(
answerTranslation(1, 'Internal runbook', EnumKnowledgeBaseVisibility.Internal),
)
expect(wrapper.getByIconName('kb-internal')).toBeInTheDocument()
})
it('links to the answer view for a user who may browse the knowledge base', () => {
const item = answerTranslation(1, 'Ocarina tuning')
const wrapper = renderAnswer(item)
expect(wrapper.getByText('Ocarina tuning').closest('a')).toHaveAttribute(
'href',
`/desktop/knowledge-base/locale/${LOCALE}/answer/${getIdFromGraphQLId(item.answer.id)}`,
)
})
// A customer reads the answer inside Zammad, not on the public help site. The answer route is
// guarded by `canBrowse` rather than by a knowledge base permission, so whenever a customer can
// see this group at all they can open that route too.
it('links a customer to the answer inside Zammad', () => {
mockPermissions(['ticket.customer'])
mockApplicationConfig({ kb_active: true, kb_active_publicly: true })
const item = answerTranslation(3, 'Ocarina tuning')
const wrapper = renderAnswer(item)
expect(wrapper.getByText('Ocarina tuning').closest('a')).toHaveAttribute(
'href',
`/desktop/knowledge-base/locale/${LOCALE}/answer/${getIdFromGraphQLId(item.answer.id)}`,
)
})
// The one case the public help site is still the answer for: no browsable knowledge base, where
// the in-app route would refuse them.
it('falls back to the public help page when no knowledge base is browsable', () => {
mockPermissions(['ticket.customer'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
const wrapper = renderAnswer(answerTranslation(3, 'Ocarina tuning'))
expect(wrapper.getByText('Ocarina tuning').closest('a')).toHaveAttribute(
'href',
`/help/${LOCALE}/7/3`,
)
})
// The details popover itself is covered on the shared component
// (components/KnowledgeBaseAnswer/__tests__/KnowledgeBaseAnswerPopoverWithTrigger.spec.ts);
// what matters here is that the item hands it the right answer - the translation carries an id
// of its own, and a locale that need not be the reader's preferred one.
it('opens the details popover for its own answer and locale', async () => {
const item = answerTranslation(5, 'Ocarina tuning')
const wrapper = renderAnswer(item)
await wrapper.events.hover(wrapper.getByRole('link'))
const calls = await waitForKnowledgeBaseAnswerInfoForPopoverQueryCalls()
expect(calls.at(-1)?.variables).toEqual({ answerId: item.answer.id, locale: LOCALE })
})
// The item is a translation, so both ids are in scope and only one of them addresses the answer.
it('builds the link from the answer id, not the translation id', () => {
const item = answerTranslation(1, 'Ocarina tuning')
item.answer.id = convertToGraphQLId('KnowledgeBase::Answer', 42)
const wrapper = renderAnswer(item)
expect(wrapper.getByText('Ocarina tuning').closest('a')).toHaveAttribute(
'href',
`/desktop/knowledge-base/locale/${LOCALE}/answer/42`,
)
})
})

View File

@@ -79,6 +79,23 @@ export const DetailSearchDocument = gql`
shared
active
}
... on KnowledgeBaseAnswerTranslation {
id
title
updatedAt
visibility
answer {
id
category {
id
}
}
kbLocale {
systemLocale {
locale
}
}
}
}
}
}

View File

@@ -79,6 +79,23 @@ query detailSearch(
shared
active
}
... on KnowledgeBaseAnswerTranslation {
id
title
updatedAt
visibility
answer {
id
category {
id
}
}
kbLocale {
systemLocale {
locale
}
}
}
}
}
}

View File

@@ -40,6 +40,32 @@ export const QuickSearchDocument = gql`
}
}
}
quickSearchKnowledgeBaseAnswers: search(
search: $search
onlyIn: KnowledgeBase__Answer__Translation
limit: $limit
) {
totalCount
items {
__typename
... on KnowledgeBaseAnswerTranslation {
id
title
visibility
kbLocale {
systemLocale {
locale
}
}
answer {
id
category {
id
}
}
}
}
}
quickSearchUsers: search(search: $search, onlyIn: User, limit: $limit) {
totalCount
items {

View File

@@ -28,6 +28,34 @@ query quickSearch($search: String!, $limit: Int = 10) {
}
}
}
quickSearchKnowledgeBaseAnswers: search(
search: $search
onlyIn: KnowledgeBase__Answer__Translation
limit: $limit
) {
totalCount
items {
__typename
... on KnowledgeBaseAnswerTranslation {
id
title
visibility
kbLocale {
systemLocale {
locale
}
}
answer {
id
# Only the id, and only for the public help-site link the users who cannot open the
# agent answer view are sent to - see getKnowledgeBaseAnswerLink.
category {
id
}
}
}
}
}
quickSearchUsers: search(search: $search, onlyIn: User, limit: $limit) {
totalCount
items {

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { createPinia, setActivePinia } from 'pinia'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { EnumSearchableModels } from '#shared/graphql/types.ts'
import { useSearchPlugins } from '../index.ts'
describe('useSearchPlugins', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
// The knowledge base answer plugin is the first one gated by `show` rather than by `permissions`,
// because `canBrowse` mixes a setting with a permission and a `permissions` list cannot say
// that. `show` was declared on SearchPlugin long before anything used it, so both branches are
// covered explicitly here.
describe('the show gate', () => {
it('offers a knowledge base gated plugin when the user may browse one', () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
const { searchPluginNames } = useSearchPlugins()
expect(searchPluginNames.value).toContain(EnumSearchableModels.KnowledgeBaseAnswerTranslation)
})
it('offers it to a customer when the knowledge base is publicly available', () => {
mockPermissions(['ticket.customer'])
mockApplicationConfig({ kb_active: true, kb_active_publicly: true })
const { searchPluginNames } = useSearchPlugins()
expect(searchPluginNames.value).toContain(EnumSearchableModels.KnowledgeBaseAnswerTranslation)
})
it('withholds it when no knowledge base is enabled', () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: false, kb_active_publicly: false })
const { searchPluginNames } = useSearchPlugins()
expect(searchPluginNames.value).not.toContain(
EnumSearchableModels.KnowledgeBaseAnswerTranslation,
)
})
})
// Every plugin gets a detailed-search tab, so every plugin must be renderable - or SearchContent
// has nothing to put in the tab panel it just created. `detailSearchComponent` and
// `detailSearchHeaders` are optional on SearchPlugin, so nothing but this asserts it.
it('leaves every plugin with a table to render', () => {
mockPermissions(['ticket.agent', 'admin.user', 'admin.organization', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
const { plugins } = useSearchPlugins()
expect(
plugins.value.every((plugin) => plugin.detailSearchComponent && plugin.detailSearchHeaders),
).toBe(true)
})
})

View File

@@ -0,0 +1,31 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { EnumSearchableModels } from '#shared/graphql/types.ts'
import KnowledgeBaseAnswerTable from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerTable.vue'
import { useKnowledgeBaseAccess } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAccess.ts'
import KnowledgeBaseAnswer from '../QuickSearch/entities/KnowledgeBaseAnswer.vue'
import type { SearchPlugin } from '../types.ts'
export default <SearchPlugin>{
name: EnumSearchableModels.KnowledgeBaseAnswerTranslation,
label: __('Knowledge base answer'),
priority: 400,
quickSearchResultLabel: __('Found knowledge base answers'),
quickSearchComponent: KnowledgeBaseAnswer,
quickSearchResultKey: 'quickSearchKnowledgeBaseAnswers',
// Gated on `show` rather than on `permissions`, and deliberately: `canBrowse` is
// `kb_active_publicly || (kb_active && knowledge_base.*)`, which is both "a knowledge base is
// enabled and in use" and "no dedicated setting to switch this off". A `permissions` list
// cannot express it, because customers hold no knowledge base permission at all and are still
// meant to see published answers here.
show: () => useKnowledgeBaseAccess().canBrowse.value,
// No object manager attributes for this entity, so no advanced-filter UI either.
filtersDisabled: true,
detailSearchHeaders: ['title', 'visibility', 'updated_at'],
detailSearchComponent: KnowledgeBaseAnswerTable,
}

View File

@@ -45,8 +45,8 @@ export type SearchPlugin = {
*/
filtersDisabled?: boolean
filterPermissions?: string[]
detailSearchHeaders: string[] | ((config: ConfigList) => string[])
detailSearchComponent: Component
detailSearchHeaders?: string[] | ((config: ConfigList) => string[])
detailSearchComponent?: Component
// Either a static list or — for config-derived overrides such as the
// accounted-time unit label — a function of the application config,
// resolved reactively by the consumer (mirrors `detailSearchHeaders`).

View File

@@ -9,7 +9,7 @@ import {
} from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { useUserTaskbarTab } from '#desktop/composables/useUserTaskbarTab.ts'
import type { UserTaskbarTabEntityProps } from '../types.ts'

View File

@@ -9,7 +9,7 @@ import {
} from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { useUserTaskbarTab } from '#desktop/composables/useUserTaskbarTab.ts'
import { taskbarTabLocaleCode } from '#desktop/entities/knowledge-base/utils/taskbarTabKey.ts'
import { isTranslationMissing } from '#desktop/entities/knowledge-base/utils/translationLocale.ts'

View File

@@ -162,6 +162,19 @@ export const useAnswerFormSchema = ({
showDirtyMark: edit,
},
children: [
{
// Options, `required` and the preselection all come from the updater. Not clearable:
// an answer always belongs to a category, and there is no top level to fall back
// on (unlike the parent field of a category).
name: 'categoryId',
label: __('Category'),
type: 'treeselect',
props: {
clearable: false,
// The labels are category titles, i.e. user data, not UI copy.
noOptionsLabelTranslation: true,
},
},
{
// The state the answer is in, which takes effect as soon as it is saved - the form
// carries no date to go with it, and a transition scheduled for later is managed
@@ -179,19 +192,6 @@ export const useAnswerFormSchema = ({
options: answerVisibilityOptions,
},
},
{
// Options, `required` and the preselection all come from the updater. Not clearable:
// an answer always belongs to a category, and there is no top level to fall back
// on (unlike the parent field of a category).
name: 'categoryId',
label: __('Category'),
type: 'treeselect',
props: {
clearable: false,
// The labels are category titles, i.e. user data, not UI copy.
noOptionsLabelTranslation: true,
},
},
],
},
],

View File

@@ -0,0 +1,60 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import type { Link } from '#shared/types/router.ts'
import { useKnowledgeBaseAccess } from '../composables/useKnowledgeBaseAccess.ts'
import { knowledgeBaseAnswerRoute } from './routeLocation.ts'
// Only what the link is actually built from, rather than one caller's fragment: the ticket
// sidebar's suggestions and the quicksearch group arrive from different queries with different
// selections, and both satisfy this structurally.
export interface KnowledgeBaseAnswerLinkTranslation {
answer: {
id: string
category: {
id: string
}
}
kbLocale: {
systemLocale: {
locale: string
}
}
}
// The answer page of the public help site.
// NB: A custom address (an alternative, prettier mount point of the help site) is not applied here -
// the canonical `/help` path stays available in that case as well.
const publicAnswerLink = (translation: KnowledgeBaseAnswerLinkTranslation) => {
const { locale } = translation.kbLocale.systemLocale
const categoryId = getIdFromGraphQLId(translation.answer.category.id)
const answerId = getIdFromGraphQLId(translation.answer.id)
return `/help/${locale}/${categoryId}/${answerId}`
}
// Where a knowledge base answer takes the user - from the ticket sidebar (linked or AI suggested)
// and from the quicksearch results alike.
//
// Keyed on `canBrowse`, which is what the answer route's own guard is keyed on
// (pages/knowledge-base/routes.ts: `requiredPermission: []`, `canAccess: canBrowse`). So anyone
// who can open that route is sent there - a customer and an agent without any knowledge base
// permission included, whenever the knowledge base is publicly available. They read the answer in
// Zammad rather than being pushed out to the help site, and the popover's category row already
// links into the knowledge base the same way.
//
// The public answer page stays the fallback for the one case that is left: no browsable knowledge
// base at all, where the in-app route would refuse them. Nobody is ever offered an answer that is
// not published (the backend scopes the link list, the suggestions and the search), and that page
// shows exactly those.
export const getKnowledgeBaseAnswerLink = (
translation: KnowledgeBaseAnswerLinkTranslation,
): Link => {
if (!useKnowledgeBaseAccess().canBrowse.value) return publicAnswerLink(translation)
const { locale } = translation.kbLocale.systemLocale
return knowledgeBaseAnswerRoute(locale, translation.answer.id)
}

View File

@@ -9,7 +9,7 @@ import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.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 { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
import KnowledgeBaseAnswerLinkedTickets from './KnowledgeBaseAnswerLinkedTickets.vue'

View File

@@ -2,7 +2,7 @@
import { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
// The two enums name the same states, and their generated values are identical: the schedulable one
// is the answer's own visibility without `draft`, which stores no date and can therefore not be

View File

@@ -11,7 +11,7 @@ import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { useKnowledgeBaseAnswerDelete } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerDelete.ts'
import { useKnowledgeBaseStore } from '#desktop/entities/knowledge-base/stores/knowledgeBase.ts'
import {

View File

@@ -6,7 +6,7 @@ import { computed, toRef } from 'vue'
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import KnowledgeBaseCategoryIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseCategoryIcon/KnowledgeBaseCategoryIcon.vue'
import type { KnowledgeBaseIconSet } from '#desktop/entities/knowledge-base/types.ts'
import { useKnowledgeBaseVisibility } from '../composables/useKnowledgeBaseVisibility.ts'
@@ -16,7 +16,7 @@ interface Props {
set: KnowledgeBaseIconSet
size?: Sizes
status?: EnumKnowledgeBaseVisibility
horizontal?: boolean
breadcrumb?: boolean
}
const props = defineProps<Props>()
@@ -38,36 +38,42 @@ const tooltipText = computed(() => {
}
})
const containerClass = computed(() => {
if (props.horizontal) return 'flex'
return 'relative h-fit'
})
const metaContainerClass = computed(() => {
const baseClasses = ['flex', 'items-center', 'justify-center', 'p-0.5']
if (props.horizontal) return baseClasses
return [
...baseClasses,
const baseClasses = [
'flex',
'items-center',
'justify-center',
'p-0.5',
'absolute',
'inset-e-0',
'bottom-0',
'rounded-sm',
'translate-y-2',
'ltr:translate-x-1.5', // eslint-disable-line zammad/zammad-tailwind-ltr
'rtl:-translate-x-1.5', // eslint-disable-line zammad/zammad-tailwind-ltr
'bg-blue-200',
'dark:bg-gray-500',
]
if (props.breadcrumb)
return [...baseClasses, 'translate-y-0.5', 'bg-neutral-50', 'dark:bg-gray-500']
return [...baseClasses, 'translate-y-2', 'bg-blue-200', 'dark:bg-gray-500']
})
const currentMetaIconFixedSize = computed(() => {
if (props.size === 'small') return { width: 8, height: 8 }
return { width: 12, height: 12 }
})
</script>
<template>
<div v-tooltip="$t(tooltipText)" role="img" :class="containerClass">
<div v-tooltip="$t(tooltipText)" class="relative h-fit" role="img">
<KnowledgeBaseCategoryIcon :name="name" :set="set" :size="size" :class="currentMetaClass" />
<div v-if="currentMetaIcon" :class="metaContainerClass">
<CommonIcon :class="currentMetaClass" :name="currentMetaIcon" decorative size="xs" />
<CommonIcon
:class="currentMetaClass"
:name="currentMetaIcon"
:fixed-size="currentMetaIconFixedSize"
decorative
/>
</div>
</div>
</template>

View File

@@ -6,7 +6,7 @@ import { computed, toRef } from 'vue'
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
import CommonHighlightedText from '#desktop/components/CommonHighlightedText/CommonHighlightedText.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { useKnowledgeBaseStore } from '#desktop/entities/knowledge-base/stores/knowledgeBase.ts'
import { knowledgeBaseBrowseRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'

View File

@@ -11,8 +11,8 @@ import { i18n } from '#shared/i18n.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import CommonPopoverWithTrigger from '#desktop/components/CommonPopover/CommonPopoverWithTrigger.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { visibilityMeta } from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
import { useKnowledgeBaseAnswerNextVisibilitySchedule } from '../../composables/useKnowledgeBaseAnswerNextVisibilitySchedule.ts'

View File

@@ -58,15 +58,15 @@ const displayItems = computed(() =>
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="tiny"
size="small"
class="m-1 me-1.5 shrink-0"
:class="item.iconClass"
horizontal
breadcrumb
/>
<CommonIcon
v-else-if="!item.route && item.icon"
:name="item.icon"
size="tiny"
size="small"
class="m-1 me-1.5 shrink-0"
:class="item.iconClass"
/>
@@ -83,15 +83,15 @@ const displayItems = computed(() =>
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="tiny"
size="small"
class="shrink-0"
:class="item.iconClass"
horizontal
breadcrumb
/>
<CommonIcon
v-else-if="item.icon"
:name="item.icon"
size="tiny"
size="small"
class="shrink-0"
:class="item.iconClass"
/>
@@ -109,15 +109,15 @@ const displayItems = computed(() =>
:name="item.icon"
:set="item.iconSet"
:status="item.visibility"
size="tiny"
size="small"
class="shrink-0"
:class="item.iconClass"
horizontal
breadcrumb
/>
<CommonIcon
v-else-if="item.icon"
:name="item.icon"
size="tiny"
size="small"
class="shrink-0"
:class="item.iconClass"
/>

View File

@@ -58,17 +58,36 @@ describe('KnowledgeBaseIconStatus', () => {
expect(wrapper.getByLabelText(label)).toBeInTheDocument()
})
it('supports two layouts', async () => {
const wrapper = renderStatus({ status: 'published', horizontal: true })
it('positions the status marker for the breadcrumb', () => {
const wrapper = renderStatus({ status: 'published', breadcrumb: true })
const container = wrapper.container.querySelector('div')!
const marker = wrapper.getByIconName('unlock-fill').parentElement
expect(container).toHaveClass('flex')
expect(container).not.toHaveClass('relative')
await wrapper.rerender({ horizontal: false })
expect(container).not.toHaveClass('flex')
expect(container).toHaveClass('relative')
expect(marker).toHaveClass('translate-y-0.5', 'bg-neutral-50')
expect(marker).not.toHaveClass('translate-y-2', 'bg-blue-200')
})
it('positions the status marker for the default context', () => {
const wrapper = renderStatus({ status: 'published', breadcrumb: false })
const marker = wrapper.getByIconName('unlock-fill').parentElement
expect(marker).toHaveClass('translate-y-2', 'bg-blue-200')
expect(marker).not.toHaveClass('translate-y-0.5', 'bg-neutral-50')
})
it.each([
{ size: 'small', dimension: '8' },
{ size: 'medium', dimension: '12' },
] as const)(
'renders the status marker at $dimension px for the $size size',
({ size, dimension }) => {
const wrapper = renderStatus({ status: 'published', size })
const marker = wrapper.getByIconName('unlock-fill')
expect(marker).toHaveAttribute('width', dimension)
expect(marker).toHaveAttribute('height', dimension)
},
)
})

View File

@@ -110,6 +110,7 @@ describe('Organization Detail View - History Flyout', () => {
records: [
{
issuer: {
__typename: 'User',
id: convertToGraphQLId('User', 3),
internalId: 3,
firstname: 'Test Admin',
@@ -124,6 +125,7 @@ describe('Organization Detail View - History Flyout', () => {
createdAt: '2025-11-24T08:32:57Z',
action: 'created',
object: {
__typename: 'ObjectClass',
klass: 'Organization',
info: null,
},
@@ -151,7 +153,7 @@ describe('Organization Detail View - History Flyout', () => {
const flyout = await view.findByRole('complementary', { name: 'History' })
waitFor(() => {
await waitFor(() => {
expect(within(flyout).getByText('Test Admin Agent')).toBeVisible()
expect(within(flyout).getByText('2025-11-24 08:32')).toBeVisible()
})

View File

@@ -510,7 +510,7 @@ describe('Organization Detail View', () => {
}),
).not.toBeInTheDocument()
waitFor(() => {
await waitFor(() => {
expect(
within(organizationTicketsSection).queryByRole('heading', {
name: `${state === 'open' ? 'Closed tickets' : 'Open tickets'}`,

View File

@@ -317,7 +317,7 @@ describe('Two-factor Authentication - Security Keys', () => {
await view.events.click(view.getByRole('button', { name: 'Retry' }))
waitFor(() =>
await waitFor(() =>
expect(flyout).toHaveAccessibleName('Set up two-factor authentication: Save codes'),
)
})

View File

@@ -39,7 +39,7 @@ const formSchema = defineFormSchema([
{
type: 'permissions',
name: 'permissions',
label: 'Permissions',
label: __('Permissions'),
props: {
options: [],
},

View File

@@ -1,9 +1,14 @@
// 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 { EnumSearchableModels, EnumTicketStateColorCode } from '#shared/graphql/types.ts'
import {
EnumKnowledgeBaseVisibility,
EnumSearchableModels,
EnumTicketStateColorCode,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { mockDetailSearchQuery } from '#desktop/components/Search/graphql/queries/detailSearch.mocks.ts'
@@ -63,4 +68,56 @@ describe('search view', () => {
await expect(view.container).toBeAccessible()
})
// The knowledge base answer tab renders a table whose columns are declared locally rather than
// resolved from the object manager, and whose visibility cell is a custom slot — so it is swept
// separately from the ticket table above.
it('has no accessibility violations on the knowledge base answer tab', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockSearchCountsQuery({
searchCounts: [
{
model: EnumSearchableModels.KnowledgeBaseAnswerTranslation,
totalCount: 1,
},
],
})
mockDetailSearchQuery({
search: {
totalCount: 1,
items: [
{
__typename: 'KnowledgeBaseAnswerTranslation',
id: convertToGraphQLId('KnowledgeBase::Answer::Translation', 7),
title: 'Ocarina tuning guide',
updatedAt: '2025-02-20T10:21:14Z',
visibility: EnumKnowledgeBaseVisibility.Published,
answer: {
__typename: 'KnowledgeBaseAnswer',
id: convertToGraphQLId('KnowledgeBase::Answer', 42),
category: {
__typename: 'KnowledgeBaseCategory',
id: convertToGraphQLId('KnowledgeBase::Category', 3),
},
},
kbLocale: {
__typename: 'KnowledgeBaseLocale',
systemLocale: { __typename: 'Locale', locale: 'en-us' },
},
},
],
},
})
const view = await visitView(
`/search/Ocarina?entity=${EnumSearchableModels.KnowledgeBaseAnswerTranslation}`,
)
expect(await view.findByRole('tab', { name: /Knowledge base answer/ })).toBeInTheDocument()
await expect(view.container).toBeAccessible()
})
})

View File

@@ -88,6 +88,16 @@ describe('search view', () => {
expect(view.getByRole('tablist', { name: 'Search entity' })).toBeInTheDocument()
})
// `routeEntity` resolves an `?entity=` only when a registered plugin backs it; anything else
// falls back to tickets rather than selecting a tab with nothing behind it.
it('falls back to the ticket tab for a model that is no plugin at all', async () => {
const view = await visitView('/search/123?entity=Nonsense')
expect(
await view.findByRole('table', { name: 'Search result for: Ticket' }),
).toBeInTheDocument()
})
it('write quick search input correctly to the search view input', async () => {
const { searchContainer, view } = await visitSearchView()
@@ -123,6 +133,37 @@ describe('search view', () => {
expect(view.getByRole('table')).toBeInTheDocument()
})
it('debounces the search term before it reaches the route and the queries', async () => {
const { searchContainer, view } = await visitSearchView()
await waitForDetailSearchQueryCalls()
const searchInput = within(searchContainer).getByRole('searchbox', { name: 'Search…' })
const router = getTestRouter()
await view.events.type(searchInput, 'ing')
// The input reflects every keystroke, the route (and with it the queries)
// must not move until the typing settles.
expect(searchInput).toHaveDisplayValue('testing')
expect(router.currentRoute.value.fullPath).toBe('/search/test?entity=Ticket')
await waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/search/testing?entity=Ticket'),
)
const mocks = await waitForDetailSearchQueryCalls()
// One request for the initial term, one for the settled term - not one per keystroke.
expect(mocks).toHaveLength(2)
expect(mocks.at(-1)?.variables).toEqual({
filter: null,
limit: 30,
onlyIn: 'Ticket',
search: 'testing',
})
})
it('selects a ticket for bulk edit', async () => {
mockFormUpdaterQuery({
formUpdater: {
@@ -542,6 +583,63 @@ describe('search view', () => {
})
})
// The knowledge base answer tab. Its plugin is gated by
// `show` rather than by `permissions`, so `kb_active` plus a knowledge base permission is what
// puts the tab on screen at all.
describe('knowledge base answers', () => {
beforeEach(() => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
kb_active: true,
})
// Empty on purpose: this file checks the tab, the query and the controls around it. Rendering
// the rows is `KnowledgeBaseAnswerTable.spec.ts`'s job, and one detail-search mock answers
// every entity — so rows would arrive at whichever table is not theirs.
mockDetailSearchQuery({
search: {
totalCount: 0,
items: [],
},
})
})
it('offers a tab for them', async () => {
const { view } = await visitSearchView()
expect(await view.findByRole('tab', { name: /Knowledge base answer/ })).toBeInTheDocument()
})
it('searches that entity when its tab is selected', async () => {
const { view } = await visitSearchView()
await view.events.click(await view.findByRole('tab', { name: /Knowledge base answer/ }))
await waitFor(async () => {
const calls = await waitForDetailSearchQueryCalls()
expect(calls.at(-1)?.variables.onlyIn).toBe(
EnumSearchableModels.KnowledgeBaseAnswerTranslation,
)
})
})
// `filtersDisabled` on the plugin: knowledge base answers are no object manager object, so
// there is no attribute set the filter UI could build itself from.
it('hides the advanced filter controls on that tab', async () => {
const { view } = await visitSearchView()
expect(view.getByRole('button', { name: 'Advanced filters' })).toBeInTheDocument()
await view.events.click(await view.findByRole('tab', { name: /Knowledge base answer/ }))
await waitFor(() =>
expect(view.queryByRole('button', { name: 'Advanced filters' })).not.toBeInTheDocument(),
)
})
})
describe('customer user', () => {
it('does not load filters or show advanced search controls', async () => {
mockDetailSearchQuery({

View File

@@ -2,7 +2,7 @@
<script setup lang="ts">
import { watchThrottled } from '@vueuse/core'
import { isEqual } from 'lodash-es'
import { debounce, isEqual } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
@@ -56,6 +56,7 @@ import { decodeFilters, getSearchQueryWithoutFilters } from '../utils/searchFilt
const MAX_ITEMS = 2000
const PAGE_SIZE = 30
const SEARCH_TERM_DEBOUNCE_TIME = 500
const props = defineProps<{
searchTerm?: string
@@ -64,9 +65,20 @@ const props = defineProps<{
const route = useRoute()
const router = useRouter()
const selectedEntity = ref(
(route.query.entity as EnumSearchableModels) ?? EnumSearchableModels.Ticket,
)
const { sortedByNamePlugins, searchPluginNames } = useSearchPlugins()
// The entity a /search URL asks for, or Ticket. An `?entity=` naming something that is no plugin —
// an unknown model, or one this user may not search — falls back instead of selecting a tab with
// nothing behind it.
const routeEntity = () => {
const requested = route.query.entity as EnumSearchableModels | undefined
if (requested && searchPluginNames.value.includes(requested)) return requested
return EnumSearchableModels.Ticket
}
const selectedEntity = ref(routeEntity())
const {
filtersByEntity,
@@ -86,8 +98,6 @@ const {
const offset = ref(0)
const { sortedByNamePlugins, searchPluginNames } = useSearchPlugins()
const scrollContainerElement = useTemplateRef('scroll-container')
const searchControlsInstance = useTemplateRef('search-controls')
@@ -110,7 +120,7 @@ watch([selectedEntity, currentFiltersQueryParams], ([entity]) => {
const syncFiltersFromRoute = () => {
if (!selectedEntityHasFiltersEnabled.value) return
const queryEntity = (route.query.entity as EnumSearchableModels) ?? EnumSearchableModels.Ticket
const queryEntity = routeEntity()
// Static attributes alone (e.g. ticket.created_by_id) don't form a usable
// validation schema for deep-link decoding — they'd drop legitimate route
@@ -136,8 +146,7 @@ onBeforeMount(syncFiltersFromRoute)
// On a fresh deep-link the object-attribute schema may still be loading, so
// onBeforeMount's decode runs against an incomplete schema — re-sync once it's
// ready. Later reactivations already see a populated schema.
const initialQueryEntity =
(route.query.entity as EnumSearchableModels) ?? EnumSearchableModels.Ticket
const initialQueryEntity = routeEntity()
if (entityFieldsLoadingByEntity.value[initialQueryEntity]) {
watch(
() => entityFieldsLoadingByEntity.value[initialQueryEntity],
@@ -148,21 +157,57 @@ if (entityFieldsLoadingByEntity.value[initialQueryEntity]) {
)
}
const modelSearchTerm = computed({
get: () => props.searchTerm,
set: (searchTerm) => {
const url = buildSearchDeepLink({
// The search input holds its own immediate value, so typing stays responsive
// while the route — the single source of truth for the queries, the taskbar
// state and the page title — only follows once the user pauses. Writing the
// term to the route on every keystroke meant a history entry, a detail search
// and a counts request per character.
const searchInputTerm = ref(props.searchTerm ?? '')
const pushSearchTerm = (searchTerm: string) => {
if (searchTerm === (props.searchTerm ?? '')) return
router.push(
buildSearchDeepLink({
searchTerm,
entity: selectedEntity.value,
filters: currentFilters.value,
baseQuery: getSearchQueryWithoutFilters(router.currentRoute.value.query),
})
}),
)
}
router.push(url)
const debouncedPushSearchTerm = debounce(pushSearchTerm, SEARCH_TERM_DEBOUNCE_TIME)
// Route → input: deep links, browser history and cross-tab sync all arrive here.
watch(
() => props.searchTerm,
(searchTerm) => {
debouncedPushSearchTerm.cancel()
searchInputTerm.value = searchTerm ?? ''
},
)
const modelSearchTerm = computed({
get: () => searchInputTerm.value,
set: (searchTerm) => {
const value = searchTerm ?? ''
searchInputTerm.value = value
// Clearing the input is an explicit action rather than typing: apply it
// right away and drop any term that was still pending.
if (!value) {
debouncedPushSearchTerm.cancel()
pushSearchTerm(value)
return
}
debouncedPushSearchTerm(value)
},
})
const currentSearchTerm = computed(() => modelSearchTerm.value ?? '')
const currentSearchTerm = computed(() => props.searchTerm ?? '')
const notVisibleSearchEntities = computed(() =>
searchPluginNames.value.filter(
@@ -301,6 +346,8 @@ const { pageActive } = usePage({
},
onDeactivated: () => {
searchTaskbarSubscriptionActive.value = false
// A pending term must not navigate once another taskbar tab is on screen.
debouncedPushSearchTerm.cancel()
},
})
@@ -365,15 +412,19 @@ const refetchQueries = () => {
searchCountsQuery.refetch()
}
// Always a registered plugin: `routeEntity` never lets `selectedEntity` hold anything else. The
// optional chaining and the empty-list default are the belt to that braces - `detailSearchHeaders`
// and `detailSearchComponent` are optional on SearchPlugin, so a plugin that omits them must
// render nothing rather than throw.
const searchPlugin = computed(() => searchPluginByName[selectedEntity.value])
const { config } = storeToRefs(useApplicationStore())
const detailSearchHeaders = computed(() =>
typeof searchPlugin.value.detailSearchHeaders === 'function'
? searchPlugin.value.detailSearchHeaders(config.value)
: searchPlugin.value.detailSearchHeaders,
)
const detailSearchHeaders = computed(() => {
const headers = searchPlugin.value?.detailSearchHeaders
return typeof headers === 'function' ? headers(config.value) : (headers ?? [])
})
// Per-entity counts, accumulated from both queries. Keeping previous entries
// across an entity switch / refetch is what prevents tab badges from briefly
@@ -655,10 +706,10 @@ setOnSuccessCallback(() => {
class="relative grow overflow-y-auto px-4 pb-4"
>
<component
:is="searchPlugin.detailSearchComponent"
:is="searchPlugin?.detailSearchComponent"
:key="selectedEntity"
:table-id="`search-${selectedEntity}-table`"
:caption="$t('Search result for: %s', searchPlugin.label)"
:caption="$t('Search result for: %s', searchPlugin?.label)"
:items="searchResultItems"
:headers="detailSearchHeaders"
:total-count="searchResultTotalCount"

View File

@@ -361,4 +361,20 @@ describe('SearchContent', () => {
expect(wrapper.queryByRole('button', { name: 'Bulk actions' })).not.toBeInTheDocument()
})
// The counts query behind the tab badges asks for every registered plugin, knowledge base answers
// included - that request is what the answers tab's counter reads.
it('asks for the knowledge base answer count behind its tab badge', async () => {
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
mockApplicationConfig({ kb_active: true })
mockTicketSearchResult(0, [])
mockSearchCountsQuery({ searchCounts: [] })
renderSearchContent({ searchTerm: '123' })
const calls = await waitForSearchCountsQueryCalls()
expect(calls[0].variables.onlyIn).toContain(EnumSearchableModels.KnowledgeBaseAnswerTranslation)
})
})

View File

@@ -1,10 +1,9 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import KnowledgeBaseAnswerAttributes from './KnowledgeBaseAnswerAttributes.vue'
import { getKnowledgeBaseAnswerLink } from './utils/knowledgeBaseAnswerLink.ts'
import KnowledgeBaseAnswerAttributes from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswer/KnowledgeBaseAnswerAttributes.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import { getKnowledgeBaseAnswerLink } from '#desktop/entities/knowledge-base/utils/knowledgeBaseAnswerLink.ts'
import type { RelatedAnswer } from './types.ts'

View File

@@ -5,12 +5,12 @@ import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
import { getKnowledgeBaseAnswerLink } from '#desktop/entities/knowledge-base/utils/knowledgeBaseAnswerLink.ts'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { useKnowledgeBaseAnswerLinks } from './composables/useKnowledgeBaseAnswerLinks.ts'
import TicketKnowledgeBaseAnswer from './TicketKnowledgeBaseAnswer.vue'
import TicketKnowledgeBaseAnswerSkeleton from './TicketKnowledgeBaseAnswerSkeleton.vue'
import { getKnowledgeBaseAnswerLink } from './utils/knowledgeBaseAnswerLink.ts'
import type { RelatedAnswer } from './types.ts'

View File

@@ -8,9 +8,8 @@ import { useSessionStore } from '#shared/stores/session.ts'
import type { Link } from '#shared/types/router.ts'
import CommonPopoverWithTrigger from '#desktop/components/CommonPopover/CommonPopoverWithTrigger.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
import TicketKnowledgeBaseAnswerPopover from './TicketKnowledgeBaseAnswerPopover.vue'
import KnowledgeBaseAnswerPopover from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswer/KnowledgeBaseAnswerPopover.vue'
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBase/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
interface Props {
translation: KnowledgeBaseAnswerTranslationFragment
@@ -42,7 +41,7 @@ const hasOpenedViaLongPress = computed(
trigger-link-active-class="outline-2! outline-blue-800! text-blue-850! dark:text-blue-600!"
>
<template #popover-content>
<TicketKnowledgeBaseAnswerPopover :translation="translation" />
<KnowledgeBaseAnswerPopover :translation="translation" />
</template>
<KnowledgeBaseAnswerIcon show-tooltip :visibility="translation.visibility" size="tiny" />

View File

@@ -4,9 +4,9 @@ import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
import type { KnowledgeBaseAnswerTranslationFragment } from '#shared/graphql/types.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import { getKnowledgeBaseAnswerLink } from '#desktop/entities/knowledge-base/utils/knowledgeBaseAnswerLink.ts'
import TicketKnowledgeBaseAnswer from './TicketKnowledgeBaseAnswer.vue'
import { getKnowledgeBaseAnswerLink } from './utils/knowledgeBaseAnswerLink.ts'
interface Props {
linkedAnswers: KnowledgeBaseAnswerTranslationFragment[]

View File

@@ -104,6 +104,9 @@ describe('TicketKnowledgeBaseActions', () => {
mockApplicationConfig({
ai_provider: true,
ai_assistance_kb_answer_suggestions: true,
// An answer link only reaches the knowledge base in Zammad while one is browsable - see
// getKnowledgeBaseAnswerLink.
kb_active: true,
})
})

View File

@@ -110,7 +110,9 @@ describe('TicketKnowledgeBaseAiDraftFlyout', () => {
vi.clearAllMocks()
// The relevance score is the only thing the flyout gates, and it goes by permission.
mockPermissions(['ticket.agent', 'knowledge_base.editor', 'admin.ai_knowledge_base'])
mockApplicationConfig({})
// kb_active because an answer link only reaches the knowledge base in Zammad while one is
// browsable - see getKnowledgeBaseAnswerLink.
mockApplicationConfig({ kb_active: true })
})
it('shows the header title', async () => {

View File

@@ -3,6 +3,7 @@
import { computed } from 'vue'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
@@ -113,6 +114,9 @@ describe('TicketKnowledgeBaseAiSuggested', () => {
// Knowledge base access decides where an answer link points to.
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
// An answer link only reaches the knowledge base in Zammad while one is browsable, so the
// setting is as load-bearing as the permission - see getKnowledgeBaseAnswerLink.
mockApplicationConfig({ kb_active: true })
})
it('renders the given answers as links', async () => {
@@ -130,7 +134,7 @@ describe('TicketKnowledgeBaseAiSuggested', () => {
)
})
it('links to the public answer page for a user without knowledge base permission', async () => {
it('links to the public answer page when no knowledge base is browsable', async () => {
mockPermissions(['ticket.agent'])
const wrapper = renderSuggestions({
@@ -145,6 +149,23 @@ describe('TicketKnowledgeBaseAiSuggested', () => {
)
})
// The permission is not what decides this: a public knowledge base is browsable by anyone, so the
// answer route takes this agent just as it takes a customer. Sending them to the help site
// instead would push them out of Zammad for an answer they can read in it.
it('links an agent without knowledge base permission to the answer inside Zammad while the knowledge base is public', async () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({ kb_active_publicly: true })
const wrapper = renderSuggestions({
answers: [relatedAnswer(1, 'Reset your password')],
})
expect((await wrapper.findByText('Reset your password')).closest('a')).toHaveAttribute(
'href',
'/desktop/knowledge-base/locale/en-us/answer/1',
)
})
it('shows a waiting message while the suggestions are still being generated', async () => {
const wrapper = renderSuggestions({ pending: true })

View File

@@ -1,6 +1,7 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import {
@@ -81,6 +82,9 @@ describe('TicketKnowledgeBaseLinks', () => {
beforeEach(() => {
// Knowledge base access decides where an answer link points to.
mockPermissions(['ticket.agent', 'knowledge_base.reader'])
// An answer link only reaches the knowledge base in Zammad while one is browsable, so the
// setting is as load-bearing as the permission - see getKnowledgeBaseAnswerLink.
mockApplicationConfig({ kb_active: true })
})
it('renders every linked answer as a link with its visibility icon', async () => {
@@ -102,7 +106,7 @@ describe('TicketKnowledgeBaseLinks', () => {
expect(wrapper.getByIconName('kb-internal')).toBeInTheDocument()
})
it('links to the public answer page for a user without knowledge base permission', async () => {
it('links to the public answer page when no knowledge base is browsable', async () => {
mockPermissions(['ticket.agent'])
const wrapper = renderLinks([linkedAnswer(1, 'Reset your password')])
@@ -115,6 +119,21 @@ describe('TicketKnowledgeBaseLinks', () => {
)
})
// The permission is not what decides this: a public knowledge base is browsable by anyone, so the
// answer route takes this agent just as it takes a customer. Sending them to the help site
// instead would push them out of Zammad for an answer they can read in it.
it('links an agent without knowledge base permission to the answer inside Zammad while the knowledge base is public', async () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({ kb_active_publicly: true })
const wrapper = renderLinks([linkedAnswer(1, 'Reset your password')])
expect((await wrapper.findByText('Reset your password')).closest('a')).toHaveAttribute(
'href',
`/desktop/knowledge-base/locale/${KNOWLEDGE_BASE_LOCALE}/answer/${getIdFromGraphQLId(linkedAnswer(1, '').answer.id)}`,
)
})
it('builds the link from the answer id, not the translation id', async () => {
const translation = linkedAnswer(1, 'Reset your password')
translation.answer.id = convertToGraphQLId('KnowledgeBase::Answer', 11)

View File

@@ -1,33 +0,0 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import type { Link } from '#shared/types/router.ts'
import { knowledgeBaseAnswerRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
import type { RelatedAnswer } from '../types.ts'
// The answer page of the public help site.
// NB: A custom address (an alternative, prettier mount point of the help site) is not applied here -
// the canonical `/help` path stays available in that case as well.
const publicAnswerLink = (translation: RelatedAnswer['translation']) => {
const { locale } = translation.kbLocale.systemLocale
const categoryId = getIdFromGraphQLId(translation.answer.category.id)
const answerId = getIdFromGraphQLId(translation.answer.id)
return `/help/${locale}/${categoryId}/${answerId}`
}
// Where a knowledge base answer of the ticket sidebar (linked or AI suggested) takes the user.
//
// Agents without knowledge base permission cannot open the answer view of the agent interface, so
// they are sent to the public answer page instead. They are only ever offered published answers
// (the backend scopes both the link list and the search), which that page shows.
export const getKnowledgeBaseAnswerLink = (translation: RelatedAnswer['translation']): Link => {
if (!useSessionStore().hasPermission('knowledge_base.*')) return publicAnswerLink(translation)
const { locale } = translation.kbLocale.systemLocale
return knowledgeBaseAnswerRoute(locale, translation.answer.id)
}

View File

@@ -264,7 +264,7 @@ describe('Fields - FieldDate', () => {
const emittedInput = view.emitted().inputRaw as Array<Array<InputEvent>>
expect(emittedInput.at(-1)?.at(0)).toBe('2021-04-13T11:10:00.000Z')
expect(emittedInput.at(-1)?.at(0)).toBe('2021-04-13T11:10:00Z')
expect(input).toHaveDisplayValue('2021-04-13 11:10')
})

View File

@@ -1,6 +1,7 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import healDateRange from '#shared/components/Form/fields/FieldDate/healDateRange.ts'
import normalizeDateValue from '#shared/components/Form/fields/FieldDate/normalizeDateValue.ts'
import { dateFieldProps } from '#shared/components/Form/fields/FieldDate/types.ts'
import createInput from '#shared/form/core/createInput.ts'
import addLink from '#shared/form/features/addLink.ts'
@@ -9,7 +10,7 @@ import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
import FieldDateTimeInput from './FieldDateTimeInput.vue'
const dateFieldDefinition = createInput(FieldDateTimeInput, dateFieldProps, {
features: [addLink, formUpdaterTrigger(), healDateRange],
features: [addLink, formUpdaterTrigger(), normalizeDateValue, healDateRange],
})
export default [

View File

@@ -0,0 +1,52 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { createNode } from '@formkit/core'
import normalizeDateValue from '#shared/components/Form/fields/FieldDate/normalizeDateValue.ts'
// Feed `value` through a node carrying the feature and return what it committed.
const committed = (value: unknown) => {
const node = createNode()
normalizeDateValue(node)
node.input(value, false)
return node.value
}
// The same for the value the node is created with, which `nodeInit` dispatches through the input
// hook only after it has applied the plugins - so the feature has to be one of them here.
const committedAtCreation = (value: unknown) =>
createNode({ plugins: [normalizeDateValue], value }).value
describe('normalizeDateValue', () => {
it('drops the milliseconds the calendar emits', () => {
expect(committed('2026-09-23T10:32:00.000Z')).toBe('2026-09-23T10:32:00Z')
})
it('rewrites the local offset the text input emits to UTC', () => {
expect(committed('2026-09-23T13:32:00+03:00')).toBe('2026-09-23T10:32:00Z')
})
it('leaves a value already in that spelling untouched', () => {
expect(committed('2026-09-23T10:32:00Z')).toBe('2026-09-23T10:32:00Z')
})
it('normalizes both bounds of a range', () => {
expect(committed(['2026-09-23T13:32:00+03:00', '2026-09-24T10:32:00.000Z'])).toEqual([
'2026-09-23T10:32:00Z',
'2026-09-24T10:32:00Z',
])
})
// The baseline the field's own later values are compared against. Normalizing only what the user
// produces would leave it un-normalized, which is the bug itself.
it('normalizes the value the field is opened with', () => {
expect(committedAtCreation('2026-09-23T13:32:00+03:00')).toBe('2026-09-23T10:32:00Z')
})
it('leaves a date, an unparseable value and a non-string unchanged', () => {
expect(committed('2026-09-23')).toBe('2026-09-23')
expect(committed('not a date at all T')).toBe('not a date at all T')
expect(committed(null)).toBeNull()
})
})

View File

@@ -0,0 +1,44 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { isValid, parseISO } from 'date-fns'
import type { FormKitNode } from '@formkit/core'
// UTC and whole seconds, which is what `GraphQL::Types::ISO8601DateTime` seeds the form with
// and all the form is able to express anyway.
export const formatDateTimeValue = (date: Date) => `${date.toISOString().slice(0, 19)}Z`
const normalizeDateTimeValue = (value: string) => {
// A date carries no time and is already written one way (`yyyy-MM-dd`).
if (!value.includes('T')) return value
const date = parseISO(value)
if (!isValid(date)) return value
return formatDateTimeValue(date)
}
// One spelling for a datetime value, whoever produced it. The same instant reaches the field
// written three ways — the server seeds `2026-09-23T10:32:00Z`, the calendar emits
// `toISOString()` with its milliseconds, the text input `formatISO()` with the local offset —
// and FormKit compares the strings rather than the instants (`dirtyBehavior: 'compare'`), so a
// field put back to the value it was opened with still reads as changed.
//
// A node feature like healDateRange, for the same reason: the input hook sees every value the
// field receives, the initial one included. Normalising only what the user produces would leave
// it compared against an un-normalised baseline, which is the bug itself.
const normalizeDateValue = (node: FormKitNode) => {
node.hook.input((payload, next) => {
if (typeof payload === 'string') return next(normalizeDateTimeValue(payload))
if (Array.isArray(payload)) {
return next(
payload.map((value) => (typeof value === 'string' ? normalizeDateTimeValue(value) : value)),
)
}
return next(payload)
})
}
export default normalizeDateValue

View File

@@ -1072,6 +1072,7 @@ export enum EnumPublicLinksScreen {
/** All searchable models */
export enum EnumSearchableModels {
KnowledgeBaseAnswerTranslation = 'KnowledgeBase__Answer__Translation',
Organization = 'Organization',
Ticket = 'Ticket',
User = 'User'
@@ -1433,7 +1434,7 @@ export type ImportJob = {
};
/** Objects found by search */
export type Item = Organization | Ticket | User;
export type Item = KnowledgeBaseAnswerTranslation | Organization | Ticket | User;
/** Jobs */
export type Job = {

View File

@@ -13,6 +13,14 @@ export type BetaUiSendFeedbackMutationVariables = Exact<{
export type BetaUiSendFeedbackMutation = { betaUiSendFeedback: { __typename: 'BetaUiSendFeedbackPayload', success: boolean } | null | undefined };
export type KnowledgeBaseAnswerInfoForPopoverQueryVariables = Exact<{
answerId: string | number;
locale?: string | null | undefined;
}>;
export type KnowledgeBaseAnswerInfoForPopoverQuery = { knowledgeBaseAnswer: { __typename: 'KnowledgeBaseAnswer', id: string, translation: { __typename: 'KnowledgeBaseAnswerTranslation', id: string, title: string, visibility: Types.EnumKnowledgeBaseVisibility, categoryTreeTranslation: Array<{ __typename: 'KnowledgeBaseCategoryTranslation', id: string, title: string }>, content: { __typename: 'KnowledgeBaseAnswerTranslationContent', bodyExcerpt: string | null | undefined }, answer: { __typename: 'KnowledgeBaseAnswer', id: string, archivedAt: string | null | undefined, publishedAt: string | null | undefined, internalAt: string | null | undefined, tags: Array<string> | null | undefined, category: { __typename: 'KnowledgeBaseCategory', id: string } }, kbLocale: { __typename: 'KnowledgeBaseLocale', systemLocale: { __typename: 'Locale', locale: string, name: string } } } | null | undefined } };
export type OrganizationInfoForPopoverQueryVariables = Exact<{
organizationId: string | number;
membersCount?: number | null | undefined;
@@ -33,6 +41,7 @@ export type DetailSearchQueryVariables = Exact<{
export type DetailSearchQuery = { search: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation', id: string, title: string, updatedAt: string, visibility: Types.EnumKnowledgeBaseVisibility, answer: { __typename: 'KnowledgeBaseAnswer', id: string, category: { __typename: 'KnowledgeBaseCategory', id: string } }, kbLocale: { __typename: 'KnowledgeBaseLocale', systemLocale: { __typename: 'Locale', locale: string } } }
| { __typename: 'Organization', id: string, internalId: number, name: string | null | undefined, shared: boolean | null | undefined, active: boolean | null | undefined }
| { __typename: 'Ticket', id: string, internalId: number, title: string, number: string, stateColorCode: Types.EnumTicketStateColorCode, createdAt: string, customer: { __typename: 'User', id: string, fullname: string | null | undefined }, owner: { __typename: 'User', id: string, fullname: string | null | undefined }, group: { __typename: 'Group', id: string, name: string | null | undefined }, state: { __typename: 'TicketState', id: string, name: string }, priority: { __typename: 'TicketPriority', id: string, name: string, uiColor: string | null | undefined }, policy: { __typename: 'PolicyTicket', update: boolean } }
| { __typename: 'User', id: string, internalId: number, login: string | null | undefined, firstname: string | null | undefined, lastname: string | null | undefined, active: boolean | null | undefined, organization: { __typename: 'Organization', id: string, name: string | null | undefined } | null | undefined, secondaryOrganizations: { __typename: 'OrganizationConnection', totalCount: number, edges: Array<{ __typename: 'OrganizationEdge', node: { __typename: 'Organization', id: string, name: string | null | undefined } }> } | null | undefined }
@@ -45,14 +54,22 @@ export type QuickSearchQueryVariables = Exact<{
export type QuickSearchQuery = { quickSearchOrganizations: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation' }
| { __typename: 'Organization', id: string, internalId: number, name: string | null | undefined, active: boolean | null | undefined }
| { __typename: 'Ticket' }
| { __typename: 'User' }
> }, quickSearchTickets: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation' }
| { __typename: 'Organization' }
| { __typename: 'Ticket', id: string, internalId: number, title: string, number: string, stateColorCode: Types.EnumTicketStateColorCode, state: { __typename: 'TicketState', id: string, name: string } }
| { __typename: 'User' }
> }, quickSearchKnowledgeBaseAnswers: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation', id: string, title: string, visibility: Types.EnumKnowledgeBaseVisibility, kbLocale: { __typename: 'KnowledgeBaseLocale', systemLocale: { __typename: 'Locale', locale: string } }, answer: { __typename: 'KnowledgeBaseAnswer', id: string, category: { __typename: 'KnowledgeBaseCategory', id: string } } }
| { __typename: 'Organization' }
| { __typename: 'Ticket' }
| { __typename: 'User' }
> }, quickSearchUsers: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation' }
| { __typename: 'Organization' }
| { __typename: 'Ticket' }
| { __typename: 'User', id: string, internalId: number, fullname: string | null | undefined, active: boolean | null | undefined }
@@ -1330,6 +1347,7 @@ export type SearchQueryVariables = Exact<{
export type SearchQuery = { search: { __typename: 'SearchResult', totalCount: number, items: Array<
| { __typename: 'KnowledgeBaseAnswerTranslation' }
| { __typename: 'Organization', id: string, internalId: number, active: boolean | null | undefined, name: string | null | undefined, vip: boolean | null | undefined, updatedAt: string, members: { __typename: 'UserConnection', totalCount: number, edges: Array<{ __typename: 'UserEdge', node: { __typename: 'User', id: string, fullname: string | null | undefined } }> } | null | undefined, updatedBy: { __typename: 'User', id: string, fullname: string | null | undefined } | null | undefined, ticketsCount: { __typename: 'TicketCount', open: number, closed: number } | null | undefined }
| { __typename: 'Ticket', id: string, internalId: number, title: string, number: string, updatedAt: string, stateColorCode: Types.EnumTicketStateColorCode, state: { __typename: 'TicketState', id: string, name: string }, priority: { __typename: 'TicketPriority', name: string, defaultCreate: boolean, uiColor: string | null | undefined }, customer: { __typename: 'User', id: string, internalId: number, fullname: string | null | undefined }, updatedBy: { __typename: 'User', id: string, fullname: string | null | undefined } | null | undefined }
| { __typename: 'User', id: string, internalId: number, firstname: string | null | undefined, lastname: string | null | undefined, image: string | null | undefined, active: boolean | null | undefined, outOfOffice: boolean | null | undefined, outOfOfficeStartAt: string | null | undefined, outOfOfficeEndAt: string | null | undefined, vip: boolean | null | undefined, updatedAt: string, organization: { __typename: 'Organization', id: string, internalId: number, name: string | null | undefined } | null | undefined, updatedBy: { __typename: 'User', id: string, fullname: string | null | undefined } | null | undefined, ticketsCount: { __typename: 'TicketCount', open: number, closed: number } | null | undefined }
@@ -1380,6 +1398,7 @@ export type AutocompleteSearchGenericQueryVariables = Exact<{
export type AutocompleteSearchGenericQuery = { autocompleteSearchGeneric: Array<{ __typename: 'AutocompleteSearchGenericEntry', value: number, label: string, labelPlaceholder: Array<string> | null | undefined, heading: string | null | undefined, headingPlaceholder: Array<string> | null | undefined, disabled: boolean | null | undefined, object:
| { __typename: 'KnowledgeBaseAnswerTranslation' }
| { __typename: 'Organization', id: string, internalId: number, name: string | null | undefined, active: boolean | null | undefined, vip: boolean | null | undefined, allMembers: { __typename: 'UserConnection', edges: Array<{ __typename: 'UserEdge', node: { __typename: 'User', id: string, internalId: number, login: string | null | undefined, image: string | null | undefined, firstname: string | null | undefined, lastname: string | null | undefined, fullname: string | null | undefined, email: string | null | undefined, phone: string | null | undefined, outOfOffice: boolean | null | undefined, outOfOfficeStartAt: string | null | undefined, outOfOfficeEndAt: string | null | undefined, active: boolean | null | undefined, vip: boolean | null | undefined, hasSecondaryOrganizations: boolean | null | undefined } }> } | null | undefined }
| { __typename: 'Ticket' }
| { __typename: 'User', id: string, internalId: number, login: string | null | undefined, image: string | null | undefined, firstname: string | null | undefined, lastname: string | null | undefined, fullname: string | null | undefined, email: string | null | undefined, phone: string | null | undefined, outOfOffice: boolean | null | undefined, outOfOfficeStartAt: string | null | undefined, outOfOfficeEndAt: string | null | undefined, active: boolean | null | undefined, vip: boolean | null | undefined, hasSecondaryOrganizations: boolean | null | undefined, organization: { __typename: 'Organization', id: string, internalId: number, name: string | null | undefined, active: boolean | null | undefined, vip: boolean | null | undefined, ticketsCount: { __typename: 'TicketCount', open: number, closed: number } | null | undefined } | null | undefined }

View File

@@ -239,5 +239,6 @@ export interface ConfigList {
vectordb_enabled: boolean
websocket_backend: string
websocket_port: string
// This is also required for packages (addons) in docker environments, as their settings cannot be part of this generated file (no database at image build time).
[key: string]: unknown
}

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