Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a69c6b85b | ||
|
|
a0403dfc9f | ||
|
|
fc5b811f11 | ||
|
|
44a5dd764f | ||
|
|
30e0b14ddf | ||
|
|
cf65e3b09b | ||
|
|
9d2c6cf523 | ||
|
|
280693014a | ||
|
|
5bd572d2ac | ||
|
|
fac22c1cf6 | ||
|
|
7689d03b89 | ||
|
|
e14dae3f7b | ||
|
|
54f25639d3 | ||
|
|
6d2da5843f | ||
|
|
c495d84328 | ||
|
|
b455ec4d37 | ||
|
|
f21960b4d6 | ||
|
|
98a6bcc601 | ||
|
|
da8a8dd958 | ||
|
|
a4c23643eb | ||
|
|
886134ceb5 | ||
|
|
3f063a7014 | ||
|
|
1976b06c01 | ||
|
|
25623e2928 | ||
|
|
556937b6de | ||
|
|
8a7a138f4e | ||
|
|
252fa26d6f | ||
|
|
893ac7943e | ||
|
|
c7623e7c68 | ||
|
|
a3a781ae36 | ||
|
|
6f41176e43 | ||
|
|
1a9661dfc1 | ||
|
|
e0deaf1780 | ||
|
|
94a6f01d75 | ||
|
|
8bbd65fbb7 | ||
|
|
c16f6c57b9 | ||
|
|
7c11c7bb5e | ||
|
|
c6f201a923 | ||
|
|
17c7b30ef6 | ||
|
|
876ea9ced5 | ||
|
|
e45d33852a | ||
|
|
b20c13b78f |
@@ -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
2
.gitignore
vendored
@@ -123,6 +123,8 @@ schema.graphql
|
||||
|
||||
# Packages
|
||||
*.zpm
|
||||
!.gitlab/docker/*.zpm
|
||||
/packages/
|
||||
|
||||
# Devcontainer
|
||||
/.devcontainer/with-mailserver/certs/*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')"
|
||||
|
||||
39
.gitlab/docker/ZammadTestA-1.0.0.yaml
Normal file
39
.gitlab/docker/ZammadTestA-1.0.0.yaml
Normal 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
|
||||
31
.gitlab/docker/ZammadTestA-1.0.0.zpm
Normal file
31
.gitlab/docker/ZammadTestA-1.0.0.zpm
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
37
.gitlab/docker/ZammadTestB-1.0.0.yaml
Normal file
37
.gitlab/docker/ZammadTestB-1.0.0.yaml
Normal 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
|
||||
28
.gitlab/docker/ZammadTestB-1.0.0.zpm
Normal file
28
.gitlab/docker/ZammadTestB-1.0.0.zpm
Normal 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
41
.gitlab/docker/yaml_to_zpm.rb
Executable 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -19,7 +19,7 @@ class App.UiElement.sla_times
|
||||
|
||||
# apply hour picker
|
||||
item.find('.js-timeConvertFrom').timepicker(
|
||||
maxHours: 999
|
||||
maxHours: 99999
|
||||
)
|
||||
|
||||
# disable/enable rows
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -79,6 +79,23 @@ export const DetailSearchDocument = gql`
|
||||
shared
|
||||
active
|
||||
}
|
||||
... on KnowledgeBaseAnswerTranslation {
|
||||
id
|
||||
title
|
||||
updatedAt
|
||||
visibility
|
||||
answer {
|
||||
id
|
||||
category {
|
||||
id
|
||||
}
|
||||
}
|
||||
kbLocale {
|
||||
systemLocale {
|
||||
locale
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,23 @@ query detailSearch(
|
||||
shared
|
||||
active
|
||||
}
|
||||
... on KnowledgeBaseAnswerTranslation {
|
||||
id
|
||||
title
|
||||
updatedAt
|
||||
visibility
|
||||
answer {
|
||||
id
|
||||
category {
|
||||
id
|
||||
}
|
||||
}
|
||||
kbLocale {
|
||||
systemLocale {
|
||||
locale
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ const formSchema = defineFormSchema([
|
||||
{
|
||||
type: 'permissions',
|
||||
name: 'permissions',
|
||||
label: 'Permissions',
|
||||
label: __('Permissions'),
|
||||
props: {
|
||||
options: [],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user