Maintenance: Switch from eslint only setup to oxlint together with eslint.

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
Dominik Klein
2025-06-11 19:45:28 +02:00
parent 1b7818a415
commit 0b7ea44b25
1211 changed files with 8455 additions and 19504 deletions

View File

@@ -1,12 +1,9 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
trim_trailing_whitespace = true
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
end_of_line = lf
max_line_length = 100

View File

@@ -8,12 +8,10 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-require-imports
const requireIndex = require('requireindex')
//------------------------------------------------------------------------------
// Plugin Definition
//------------------------------------------------------------------------------
// import all rules in lib/rules
module.exports.rules = requireIndex(`${__dirname}/rules`)
module.exports = { meta: { name: 'zammad-eslint' }, rules: requireIndex(`${__dirname}/rules`) }

View File

@@ -26,18 +26,18 @@ module.exports = {
},
create(context) {
const year = new Date().getYear() + 1900
const year = new Date().getFullYear()
let expectedComment = `// Copyright (C) 2012-${year} Zammad Foundation, https://zammad-foundation.org/`
let findComment = '// Copyright'
if (context.getFilename().endsWith('.vue')) {
if (context.filename.endsWith('.vue')) {
expectedComment = `<!-- Copyright (C) 2012-${year} Zammad Foundation, https://zammad-foundation.org/ -->`
findComment = '<!-- Copyright'
}
return {
Program(node) {
const firstLine = context.getSourceCode().lines[0]
const firstLine = context.sourceCode.lines[0]
if (!firstLine.length) return
if (firstLine === expectedComment) return
if (firstLine.startsWith(findComment)) {

View File

@@ -30,8 +30,7 @@ module.exports = {
/^[^A-Z]/, // Only look at strings starting with upper case letters
/\$\{/, // Ignore strings with interpolation
]
const IGNORE_METHODS = ['__']
const IGNORE_OBJECTS = ['log', 'console', 'i18n']
const IGNORE_OBJECTS = new Set(['log', 'console', 'i18n'])
return {
Literal(node) {
@@ -61,10 +60,10 @@ module.exports = {
const { parent } = node
if (parent.type === 'CallExpression') {
if (IGNORE_METHODS.includes(parent.callee.name)) return
if (parent.callee.name == '__') return
if (
parent.callee.type === 'MemberExpression' &&
IGNORE_OBJECTS.includes(parent.callee.object.name)
IGNORE_OBJECTS.has(parent.callee.object.name)
) {
return
}
@@ -72,8 +71,7 @@ module.exports = {
context.report({
node,
message:
'This string looks like it should be marked as translatable via __(...)',
message: 'This string looks like it should be marked as translatable via __(...)',
fix(fixer) {
return fixer.replaceText(node, `__(${node.raw})`)
},

View File

@@ -10,7 +10,7 @@
//------------------------------------------------------------------------------
/**
* @type {import('eslint').Rule.RuleModule}
* @type {RegExp}
*/
const kebabCaseRegex = /^[a-z-]+$/
@@ -26,16 +26,18 @@ module.exports = {
},
create(context) {
return {
// eslint-disable-next-line object-shorthand
'CallExpression[callee.name="Symbol"]'(node) {
const descriptor = node.arguments[0]
if (!descriptor && typeof descriptor.value !== 'string') return
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'Symbol') return
if (!kebabCaseRegex.test(descriptor.value))
const descriptor = node.arguments[0]
if (!descriptor || typeof descriptor.value !== 'string') return
if (!kebabCaseRegex.test(descriptor.value)) {
context.report({
node: descriptor,
message: 'Symbol description should be in kebab-case.',
})
}
},
}
},

View File

@@ -51,9 +51,7 @@ const parsePossibleClassString = (classesList) => {
})
classes.forEach((className) => {
const match = className.match(
new RegExp(`^(rtl|ltr):(${baseClass})-([^\n]+)`),
)
const match = className.match(new RegExp(`^(rtl|ltr):(${baseClass})-([^\n]+)`))
if (!match) return
const [, dir, prefix, value] = match
if (value === '0') return
@@ -102,8 +100,7 @@ module.exports = {
if (errors.length) {
context.report({
loc: node.loc,
message:
'When positioning classes are used, they must be prefixed with ltr/rtl.',
message: 'When positioning classes are used, they must be prefixed with ltr/rtl.',
fix(fixer) {
const newClasses = [...classes]
errors.forEach(({ remove, add }) => {
@@ -117,10 +114,7 @@ module.exports = {
}
})
})
return fixer.replaceText(
node,
`${quotes}${newClasses.join(' ')}${quotes}`,
)
return fixer.replaceText(node, `${quotes}${newClasses.join(' ')}${quotes}`)
},
})
}

View File

@@ -20,17 +20,17 @@
"neverBuiltDependencies": []
},
"devDependencies": {
"eslint": "^8.57.1",
"eslint": "^9.0.0",
"eslint-plugin-eslint-plugin": "^5.5.1",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-n": "^16.0.0",
"mocha": "^11.5.0",
"vue-eslint-parser": "^10.1.3"
},
"engines": {
"node": "12.x || 14.x || >= 16"
"node": ">=18.12.0"
},
"peerDependencies": {
"eslint": ">=6"
"eslint": ">=9"
},
"license": "ISC"
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,23 @@
// Requirements
//------------------------------------------------------------------------------
/* eslint-disable @typescript-eslint/no-require-imports */
const { RuleTester } = require('eslint')
const rule = require('../../../lib/rules/zammad-copyright.js')
/* eslint-enable @typescript-eslint/no-require-imports */
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
// NOTE: Cannot test with xml tags inside vue files, as the preprocessors are not running.
const year = new Date().getYear() + 1900
const ruleTester = new RuleTester()
const year = new Date().getFullYear()
const ruleTester = new RuleTester({
languageOptions: {
parser: require('vue-eslint-parser'), // required to parse .vue files properly otherwise js-parser will be used
}
})
ruleTester.run('zammad-copyright', rule, {
valid: [
{

View File

@@ -9,11 +9,9 @@
// Requirements
//------------------------------------------------------------------------------
/* eslint-disable @typescript-eslint/no-require-imports */
const { RuleTester } = require('eslint')
const rule = require('../../../lib/rules/zammad-detect-translatable-string.js')
/* eslint-enable @typescript-eslint/no-require-imports */
//------------------------------------------------------------------------------
// Tests
@@ -48,7 +46,6 @@ ruleTester.run('zammad-detect-translatable-string', rule, {
},
{
filename: 'test.ts',
// eslint-disable-next-line no-template-curly-in-string
code: '"String with ${interpolation}..."', // Not fully correct, but a ``-template string does not seem to work.
},
],

View File

@@ -1,5 +1,6 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
/**
* @fileoverview Enforce kebab-case for Symbol descriptors
* @author Benjamin Scharf
@@ -9,22 +10,18 @@
// Requirements
//------------------------------------------------------------------------------
/* eslint-disable @typescript-eslint/no-require-imports */
const { RuleTester } = require('eslint')
const rule = require('../../../lib/rules/zammad-symbol-description.js')
/* eslint-enable @typescript-eslint/no-require-imports */
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
const ruleTester = new RuleTester({
parser: require.resolve('vue-eslint-parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
languageOptions: {
parser: require('vue-eslint-parser'), // required to parse .vue files properly otherwise js-parser will be used
}
})
ruleTester.run('zammad-symbol-descriptor', rule, {
@@ -81,11 +78,6 @@ ruleTester.run('zammad-symbol-descriptor', rule, {
code: `Symbol('fooBar')`,
errors: [{ message: 'Symbol description should be in kebab-case.' }],
},
{
filename: 'test.ts',
code: `Symbol('foo bar')`,
errors: [{ message: 'Symbol description should be in kebab-case.' }],
},
{
filename: 'test.vue',
code: `<script lang="ts" setup> Symbol('FooBar')</script>`,

View File

@@ -9,11 +9,9 @@
// Requirements
//------------------------------------------------------------------------------
/* eslint-disable @typescript-eslint/no-require-imports */
const { RuleTester } = require('eslint')
const rule = require('../../../lib/rules/zammad-tailwind-ltr.js')
/* eslint-enable @typescript-eslint/no-require-imports */
//------------------------------------------------------------------------------
// Tests

View File

@@ -1,18 +0,0 @@
**/*{.,-}min.js
# Skip auto generated files.
app/frontend/**/graphql/**/*.ts
!app/frontend/tests/graphql/**/*.ts
app/frontend/shared/graphql/types.ts
app/frontend/shared/types/config.ts
/.dev/cypress/dist/*
# Skip legacy files
public/assets/tests/**/*.js
public/assets/form/**/*.js
public/assets/frontend/vite/**/*.js
public/assets/frontend/vite-dev/**/*.js
# Cache
tmp/**/*

212
.eslintrc
View File

@@ -1,212 +0,0 @@
ecmaFeatures:
modules: true
jsx: true
env:
amd: true
browser: true
es6: true
jquery: true
node: true
# http://eslint.org/docs/rules/
rules:
# Possible Errors
comma-dangle: [2, never]
no-cond-assign: 2
no-console: 0
no-constant-condition: 2
no-control-regex: 2
no-debugger: 2
no-dupe-args: 2
no-dupe-keys: 2
no-duplicate-case: 2
no-empty: 2
no-empty-character-class: 2
no-ex-assign: 2
no-extra-boolean-cast: 2
no-extra-parens: 0
no-extra-semi: 2
no-func-assign: 2
no-inner-declarations: [2, functions]
no-invalid-regexp: 2
no-irregular-whitespace: 2
no-negated-in-lhs: 2
no-obj-calls: 2
no-regex-spaces: 2
no-sparse-arrays: 2
no-unexpected-multiline: 2
no-unreachable: 2
use-isnan: 2
valid-jsdoc: 0
valid-typeof: 2
# Best Practices
accessor-pairs: 2
block-scoped-var: 0
complexity: [2, 6]
consistent-return: 0
curly: 0
default-case: 0
dot-location: 0
dot-notation: 0
eqeqeq: 2
guard-for-in: 2
no-alert: 2
no-caller: 2
no-case-declarations: 2
no-div-regex: 2
no-else-return: 0
no-labels: 2
no-empty-pattern: 2
no-eq-null: 2
no-eval: 2
no-extend-native: 2
no-extra-bind: 2
no-fallthrough: 2
no-floating-decimal: 0
no-implicit-coercion: 0
no-implied-eval: 2
no-invalid-this: 0
no-iterator: 2
no-lone-blocks: 2
no-loop-func: 2
no-magic-number: 0
no-multi-spaces: 0
no-multi-str: 0
no-native-reassign: 2
no-new-func: 2
no-new-wrappers: 2
no-new: 2
no-octal-escape: 2
no-octal: 2
no-proto: 2
no-redeclare: 2
no-return-assign: 2
no-script-url: 2
no-self-compare: 2
no-sequences: 0
no-throw-literal: 0
no-unused-expressions: 2
no-useless-call: 2
no-useless-concat: 2
no-void: 2
no-warning-comments: 0
no-with: 2
radix: 2
vars-on-top: 0
wrap-iife: 2
yoda: 0
# Strict
strict: 0
# Variables
init-declarations: 0
no-catch-shadow: 2
no-delete-var: 2
no-label-var: 2
no-shadow-restricted-names: 2
no-shadow: 0
no-undef-init: 2
no-undef: 0
no-undefined: 0
no-unused-vars: 0
no-use-before-define: 0
# Node.js and CommonJS
callback-return: 2
global-require: 2
handle-callback-err: 2
no-mixed-requires: 0
no-new-require: 0
no-path-concat: 2
no-process-exit: 2
no-restricted-modules: 0
no-sync: 0
# Stylistic Issues
array-bracket-spacing: 0
block-spacing: 0
brace-style: 0
camelcase: 0
comma-spacing: 0
comma-style: 0
computed-property-spacing: 0
consistent-this: 0
eol-last: 0
func-names: 0
func-style: 0
id-length: 0
id-match: 0
indent: 0
jsx-quotes: 0
key-spacing: 0
linebreak-style: 0
lines-around-comment: 0
max-depth: 0
max-len: 0
max-nested-callbacks: 0
max-params: 0
max-statements: [2, 30]
new-cap: 0
new-parens: 0
newline-after-var: 0
no-array-constructor: 0
no-bitwise: 0
no-continue: 0
no-inline-comments: 0
no-lonely-if: 0
no-mixed-spaces-and-tabs: 0
no-multiple-empty-lines: 0
no-negated-condition: 0
no-nested-ternary: 0
no-new-object: 0
no-plusplus: 0
no-restricted-syntax: 0
no-spaced-func: 0
no-ternary: 0
no-trailing-spaces: 0
no-underscore-dangle: 0
no-unneeded-ternary: 0
object-curly-spacing: 0
one-var: 0
operator-assignment: 0
operator-linebreak: 0
padded-blocks: 0
quote-props: 0
quotes: 0
require-jsdoc: 0
semi-spacing: 0
semi: 0
sort-vars: 0
space-after-keywords: 0
space-before-blocks: 0
space-before-function-paren: 0
space-before-keywords: 0
space-in-parens: 0
space-infix-ops: 0
space-return-throw-case: 0
space-unary-ops: 0
spaced-comment: 0
wrap-regex: 0
# ECMAScript 6
arrow-body-style: 0
arrow-parens: 0
arrow-spacing: 0
constructor-super: 0
generator-star-spacing: 0
no-arrow-condition: 0
no-class-assign: 0
no-const-assign: 0
no-dupe-class-members: 0
no-this-before-super: 0
no-var: 0
object-shorthand: 0
prefer-arrow-callback: 0
prefer-const: 0
prefer-reflect: 0
prefer-spread: 0
prefer-template: 0
require-yield: 0

View File

@@ -1,319 +0,0 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('path')
const mobilePagesDir = path.resolve(__dirname, 'app/frontend/apps/mobile/pages')
const mobilePagesFolder = fs.readdirSync(mobilePagesDir)
const desktopPagesDir = path.resolve(
__dirname,
'app/frontend/apps/desktop/pages',
)
const desktopPagesFolder = fs.readdirSync(desktopPagesDir)
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
plugins: [
'@typescript-eslint',
'vue',
'vuejs-accessibility',
'prettier',
'sonarjs',
'security',
'zammad',
'import',
],
extends: [
'airbnb-base',
'plugin:vue/vue3-recommended',
'plugin:vuejs-accessibility/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'@vue/prettier',
'@vue/typescript/recommended',
'prettier',
'plugin:sonarjs/recommended',
'plugin:security/recommended-legacy',
],
rules: {
'zammad/zammad-copyright': 'error',
'zammad/zammad-detect-translatable-string': 'error',
'zammad/zammad-tailwind-ltr': 'error',
'zammad/zammad-symbol-description': 'error',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'consistent-return': 'off', // allow implicit return
'class-methods-use-this': 'off',
'prefer-destructuring': [
'error',
{
VariableDeclarator: {
array: false,
object: true,
},
AssignmentExpression: {
array: false,
object: true,
},
},
{
enforceForRenamedProperties: false,
},
],
// Loosen AirBnB's strict rules a bit to allow 'for .. of'
'no-restricted-syntax': [
'error',
'ForInStatement',
// "ForOfStatement", // We want to allow this
'LabeledStatement',
'WithStatement',
],
'no-underscore-dangle': 'off',
'no-param-reassign': 'off',
'func-style': ['error', 'expression'],
'no-restricted-imports': 'off',
'import/no-extraneous-dependencies': 'off',
'import/extensions': ['error', 'ignorePackages'],
'import/prefer-default-export': 'off',
'import/no-restricted-paths': [
'error',
{
zones: [
// restrict import inside shared context from app context
{
target: './app/frontend/shared',
from: './app/frontend/apps',
},
{
target: './app/frontend/apps/desktop',
from: './app/frontend/apps/mobile',
},
{
target: './app/frontend/apps/mobile',
from: './app/frontend/apps/desktop',
},
// restrict imports between different pages folder
...mobilePagesFolder.map((page) => {
return {
target: `./app/frontend/apps/mobile/pages/!(${page})/**/*`,
from: `./app/frontend/apps/mobile/pages/${page}/**/*`,
}
}),
...desktopPagesFolder.map((page) => {
return {
target: `./app/frontend/apps/desktop/pages/!(${page})/**/*`,
from: `./app/frontend/apps/desktop/pages/${page}/**/*`,
}
}),
],
},
],
/* We strongly recommend that you do not use the no-undef lint rule on TypeScript projects. The checks it provides are already provided by TypeScript without the need for configuration - TypeScript just does this significantly better (Source: https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors). */
'no-undef': 'off',
// We need to use the extended 'no-shadow' rule from typescript:
// https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-shadow.md
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-explicit-any': ['error', { ignoreRestArgs: true }],
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'enumMember',
format: ['StrictPascalCase'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
],
'vue/component-tags-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/custom-event-name-casing': ['error', 'kebab-case'],
// Enforce ordering for imports
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
'type',
],
pathGroups: [
{
pattern: '#tests/**',
group: 'internal',
position: 'before',
},
{
pattern: '#shared/**',
group: 'internal',
position: 'before',
},
{
pattern: '#desktop/**',
group: 'internal',
},
{
pattern: '#mobile/**',
group: 'internal',
},
{
pattern: '**/types.ts',
group: 'type',
position: 'after',
},
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
// Enforce Vue v3.3+ tuple syntax for defineEmits.
'vue/define-emits-declaration': ['error', 'type-literal'],
'vue/script-setup-uses-vars': 'error',
// Don't require a default value for the props.
'vue/require-default-prop': 'off',
// Don't require multi word component names.
'vue/multi-word-component-names': 'off',
// Enforce v-bind directive usage in short form as error instead of warning.
'vue/v-bind-style': ['error', 'shorthand'],
// Enforce v-on directive usage in short form as error instead of warning.
'vue/v-on-style': ['error', 'shorthand'],
// Enforce v-slot directive usage in short form as error instead of warning.
'vue/v-slot-style': ['error', 'shorthand'],
'no-promise-executor-return': 'off',
// We have quite a lot of constant strings in our code.
'sonarjs/no-duplicate-string': 'off',
// It also supresses local function returns.
'sonarjs/prefer-immediate-return': 'off',
'sonarjs/prefer-single-boolean-return': 'off',
// Consider prettier offenses as errors.
'prettier/prettier': ['error'],
},
overrides: [
{
files: ['*.js'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off',
'security/detect-non-literal-regexp': 'off',
},
},
{
files: [
'app/frontend/tests/**',
'app/frontend/**/__tests__/**',
'app/frontend/**/*.spec.*',
'app/frontend/cypress/**',
'.eslint-plugin-zammad/**',
'.eslintrc.js',
],
rules: {
'zammad/zammad-tailwind-ltr': 'off',
'zammad/zammad-detect-translatable-string': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
// rules that require type information
{
files: ['*.ts', '*.tsx', '*.vue'],
rules: {
// handled by typescript itself with "verbatimModuleSyntax"
'@typescript-eslint/consistent-type-imports': 'off',
'@typescript-eslint/consistent-type-exports': 'error',
'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off',
'security/detect-non-literal-regexp': 'off',
},
parserOptions: {
project: ['./tsconfig.json', './app/frontend/cypress/tsconfig.json'],
},
},
],
settings: {
'import/core-modules': ['virtual:pwa-register'],
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
alias: {
map: [
[
'vue-easy-lightbox/dist/external-css/vue-easy-lightbox.css',
path.resolve(
__dirname,
'node_modules/vue-easy-lightbox/dist/external-css/vue-easy-lightbox.css',
),
],
],
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
},
},
// Adding typescript file types, because airbnb doesn't allow this by default.
'import/extensions': ['.js', '.jsx', '.ts', '.tsx', '.vue'],
},
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
},
parserOptions: {
parser: '@typescript-eslint/parser', // the typescript-parser for eslint, instead of tslint
sourceType: 'module', // allow the use of imports statements
ecmaVersion: 2020, // allow the parsing of modern ecmascript
},
}

View File

@@ -0,0 +1,35 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Overcommit::Hook::PreCommit
# Runs `eslint` against any modified JavaScript files.
#
# Protip: if you have a pnpm script set up to run eslint, you can configure
# this hook to run eslint via your pnpm script by using the `command` option in
# your .overcommit.yml file. This can be useful if you have some eslint
# configuration built into your pnpm script that you don't want to repeat
# somewhere else. Example:
#
# EsLint:
# required_executable: 'pnpm'
# enabled: true
# command: ['pnpm', 'lint:js:eslint:cmd']
#
# @see http://eslint.org/
class Eslint < Base
def run
eslint_regex = %r{^(?<file>[^\s](?:\w:)?[^:]+):[^\d]+(?<line>\d+).*?(?<type>Error|Warning)}
result = execute(command, args: applicable_files)
output = result.stdout.chomp
messages = output.split("\n").grep(eslint_regex)
# return [:fail, result.stderr] if messages.empty? && !result.success?
return [:fail, result.stdout] if messages.empty? && !result.success?
return :pass if result.success? && output.empty?
# example message:
# path/to/file.js
# 1:0 error Error message ruleName
extract_messages(messages, eslint_regex, ->(type) { type.downcase.to_sym })
end
end
end

View File

@@ -1,4 +1,4 @@
// @ts-check
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
/** @type {import('@graphql-codegen/cli').CodegenConfig['generates'][string]} */
const mockerPreset = {

View File

@@ -19,11 +19,7 @@ PreCommit:
install_command: 'npm install -g @coffeelint/cli'
# .dev/coffeelint/rules/* not supported in YAML, specify all rules separately.
flags:
[
'--reporter=csv',
'--rules',
'./.dev/coffeelint/rules/detect_translatable_string.coffee',
]
['--reporter=csv', '--rules', './.dev/coffeelint/rules/detect_translatable_string.coffee']
enabled: true
on_warn: fail
exclude: 'public/assets/chat/**/*'
@@ -61,19 +57,22 @@ PreCommit:
enabled: true
command: ['pnpm', 'lint:ts']
exclude:
- 'node_modules'
- 'public'
- 'vendor'
- 'node_modules'
- 'public'
- 'vendor'
include:
- 'app/frontend/**/*'
- '**/*.cy.ts'
- '**/cypress/**/*.ts'
- './vite.config.*'
- './vitest.setup.ts'
EsLint:
# Custom Eslint hook, necessary due to dropping of the "compact" formatter in v9.
# The hook now relies on the default "stylish" formatter and checks stdout instead.
# See `.git-hooks/pre_commit/eslint.rb` for details.
Eslint:
required_executable: 'pnpm'
enabled: true
command: ['pnpm', 'lint:js:cmd']
command: ['pnpm', 'lint:js:eslint:cmd']
exclude:
- 'app/assets/**/*'
- 'public/assets/chat/**/*'
@@ -82,6 +81,11 @@ PreCommit:
- '**/*.js'
- '**/*.ts'
- '**/*.vue'
Oxlint:
required_executable: 'pnpm'
enabled: true
command: ['pnpm', 'lint:js:oxlint:cmd']
flags: ['app/frontend/', '.eslint-plugin-zammad/']
Markdownlint:
required_executable: 'pnpm'
enabled: true
@@ -113,9 +117,9 @@ PrePush:
enabled: false
CustomScript:
enabled: true
description: "Verify GraphQL introspection file is not empty."
description: 'Verify GraphQL introspection file is not empty.'
required_executable: 'bash'
command: [ 'bash', '-c', 'test -s app/graphql/graphql_introspection.json' ]
command: ['bash', '-c', 'test -s app/graphql/graphql_introspection.json']
on_warn: fail
PreRebase:

45
.oxlintrc.json Normal file
View File

@@ -0,0 +1,45 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn", "promise", "vitest", "oxc"],
"categories": {
"correctness": "error",
"perf": "error"
},
"rules": {
"unicorn/no-thenable": "off",
"jest/no-standalone-expect": "off",
"jest/valid-expect": "off",
"jest/no-export": "off",
"promise/no-callback-in-promise ": "off",
"import/no-unassigned-import ": "off",
"typescript/no-explicit-any": "error",
"no-irregular-whitespace": "error",
"no-use-before-define": "error",
"no-empty-object-type": "error",
"import/no-cycle": "error"
},
"overrides": [{
"files": [
"app/frontend/tests/**",
"app/frontend/**/__tests__/**",
"app/frontend/**/*.spec.*",
"app/frontend/cypress/**"
],
"rules": {
"typescript/no-explicit-any": "off"
}
}],
"settings": {},
"env": {
"browser": true,
"node": true
},
"globals": {},
"ignorePatterns": [
"app/frontend/**/graphql/**/*.ts",
"!app/frontend/tests/graphql/**/*.ts",
"app/frontend/shared/graphql/types.ts",
"app/frontend/shared/types/config.ts"
]
}

View File

@@ -1,8 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["extendClasses"]
"printWidth": 100
}

View File

@@ -36,8 +36,7 @@ defineEmits<{
const locale = useLocaleStore()
const collapseButtonIcon = computed(() => {
if (props.orientation === 'vertical')
return props.collapsed ? 'arrows-expand' : 'arrows-collapse'
if (props.orientation === 'vertical') return props.collapsed ? 'arrows-expand' : 'arrows-collapse'
if (
(props.inverse && locale.localeData?.dir !== EnumTextDirection.Rtl) ||
@@ -79,9 +78,7 @@ const labels = computed(() => ({
variant="none"
:aria-controls="ownerId"
:data-test-id="`controls-${ownerId}`"
:aria-label="
collapsed ? $t('Expand this element') : $t('Collapse this element')
"
:aria-label="collapsed ? $t('Expand this element') : $t('Collapse this element')"
size="small"
@click="$emit('toggle-collapse', $event)"
/>

View File

@@ -173,37 +173,32 @@ describe('CollapseButton', () => {
ownerId: 'test',
},
})
expect(wrapper.getByRole('button').parentElement).toHaveClasses([
'opacity-0',
])
expect(wrapper.getByRole('button').parentElement).toHaveClasses(['opacity-0'])
})
it.each(['tertiary-gray', 'none'])(
'renders variant %s correctly',
(variant) => {
const wrapper = renderComponent(CollapseButton, {
props: {
variant,
ownerId: 'test',
},
})
if (variant === 'tertiary-gray') {
expect(wrapper.getByRole('button')).toHaveClasses([
'focus-visible:bg-blue-800',
'active:dark:bg-blue-800',
'focus:dark:bg-blue-800',
'active:bg-blue-800',
'focus:bg-blue-800',
'hover:bg-blue-600',
'hover:dark:bg-blue-900',
'text-black',
'dark:bg-gray-200',
'dark:text-white',
])
}
expect(wrapper.getByRole('button')).toHaveClasses([])
},
)
it.each(['tertiary-gray', 'none'])('renders variant %s correctly', (variant) => {
const wrapper = renderComponent(CollapseButton, {
props: {
variant,
ownerId: 'test',
},
})
if (variant === 'tertiary-gray') {
expect(wrapper.getByRole('button')).toHaveClasses([
'focus-visible:bg-blue-800',
'active:dark:bg-blue-800',
'focus:dark:bg-blue-800',
'active:bg-blue-800',
'focus:bg-blue-800',
'hover:bg-blue-600',
'hover:dark:bg-blue-900',
'text-black',
'dark:bg-gray-200',
'dark:text-white',
])
}
expect(wrapper.getByRole('button')).toHaveClasses([])
})
it('shows always for touch devices', () => {
// Impersonate a touch device by mocking the corresponding media query.

View File

@@ -6,24 +6,13 @@ import { nextTick } from 'vue'
import { useCollapseHandler } from '#desktop/components/CollapseButton/useCollapseHandler.ts'
describe('useCollapseHandler', async () => {
describe('useCollapseHandler', () => {
const emit = vi.fn()
beforeEach(() => {
localStorage.clear()
})
it('initializes with collapsed state from local storage', async () => {
const TestComponent = {
setup() {
const { isCollapsed } = useCollapseHandler(emit, { storageKey: 'test' })
expect(isCollapsed.value).toBe(false)
},
template: '<div></div>',
}
mount(TestComponent)
})
it('sync local storage state on initial load', async () => {
localStorage.setItem('test', 'true')
const TestComponent = {

View File

@@ -11,10 +11,7 @@ import type { CollapseOptions, CollapseEmit } from './types.ts'
* @args emit - The emit function from the setup function
* @args options.storageKey - The key to store the collapse state in local storage
* * */
export const useCollapseHandler = (
emit: CollapseEmit,
options?: CollapseOptions,
) => {
export const useCollapseHandler = (emit: CollapseEmit, options?: CollapseOptions) => {
let isCollapsed: Ref<boolean>
if (options?.storageKey) {
@@ -23,8 +20,7 @@ export const useCollapseHandler = (
isCollapsed = ref(false)
}
const callEmit = () =>
isCollapsed.value ? emit('collapse', true) : emit('expand', true)
const callEmit = () => (isCollapsed.value ? emit('collapse', true) : emit('expand', true))
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value

View File

@@ -5,19 +5,13 @@ import { computed, toRefs } from 'vue'
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
import CommonPopover from '#shared/components/CommonPopover/CommonPopover.vue'
import type {
Orientation,
Placement,
} from '#shared/components/CommonPopover/types.ts'
import type { Orientation, Placement } from '#shared/components/CommonPopover/types.ts'
import { usePopover } from '#shared/components/CommonPopover/usePopover.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import getUuid from '#shared/utils/getUuid.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import type {
ButtonSize,
ButtonVariant,
} from '#desktop/components/CommonButton/types.ts'
import type { ButtonSize, ButtonVariant } from '#desktop/components/CommonButton/types.ts'
import CommonPopoverMenu from '#desktop/components/CommonPopoverMenu/CommonPopoverMenu.vue'
import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
import { usePopoverMenu } from '#desktop/components/CommonPopoverMenu/usePopoverMenu.ts'
@@ -49,8 +43,11 @@ const { popover, isOpen: popoverIsOpen, popoverTarget, toggle } = usePopover()
const { actions, entity } = toRefs(props)
const { filteredMenuItems, singleMenuItemPresent, singleMenuItem } =
usePopoverMenu(actions, entity, { provides: true })
const { filteredMenuItems, singleMenuItemPresent, singleMenuItem } = usePopoverMenu(
actions,
entity,
{ provides: true },
)
const entityId = computed(() => props.entity?.id || getUuid())
const menuId = computed(() => `popover-${entityId.value}`)
@@ -90,10 +87,7 @@ const variantClasses = computed(() => {
</script>
<template>
<div
v-if="filteredMenuItems && filteredMenuItems.length > 0"
class="inline-block"
>
<div v-if="filteredMenuItems && filteredMenuItems.length > 0" class="inline-block">
<template v-if="singleActionMode">
<CommonLink
v-if="singleMenuItem?.link"
@@ -140,8 +134,7 @@ const variantClasses = computed(() => {
buttonVariantClassExtension,
]"
:variant="
defaultButtonVariant !== 'neutral-dark' &&
defaultButtonVariant !== 'neutral-light'
defaultButtonVariant !== 'neutral-dark' && defaultButtonVariant !== 'neutral-light'
? defaultButtonVariant
: 'neutral'
"

View File

@@ -33,10 +33,7 @@ describe('CommonActionMenu', () => {
},
]
const renderActionMenu = async (
actions: MenuItem[],
props?: Partial<Props>,
) => {
const renderActionMenu = async (actions: MenuItem[], props?: Partial<Props>) => {
return renderComponent(CommonActionMenu, {
router: true,
props: {
@@ -72,9 +69,7 @@ describe('CommonActionMenu', () => {
},
])
expect(
view.queryByIconName('three-dots-vertical'),
).not.toBeInTheDocument()
expect(view.queryByIconName('three-dots-vertical')).not.toBeInTheDocument()
})
it('calls onClick handler when action is clicked', async () => {
@@ -94,9 +89,7 @@ describe('CommonActionMenu', () => {
const view = await renderActionMenu(actions)
await view.events.click(view.getByIconName('three-dots-vertical'))
const id = view
.getByLabelText('Action menu button')
.getAttribute('aria-controls')
const id = view.getByLabelText('Action menu button').getAttribute('aria-controls')
const popover = document.getElementById(id as string)
@@ -112,9 +105,7 @@ describe('CommonActionMenu', () => {
customMenuButtonLabel: 'Custom Action Menu Label',
})
expect(
view.getByLabelText('Custom Action Menu Label'),
).toBeInTheDocument()
expect(view.getByLabelText('Custom Action Menu Label')).toBeInTheDocument()
})
})
@@ -128,9 +119,7 @@ describe('CommonActionMenu', () => {
it('supports single action mode', async () => {
const view = await renderActionMenu([actions[0]])
expect(
view.queryByIconName('three-dots-vertical'),
).not.toBeInTheDocument()
expect(view.queryByIconName('three-dots-vertical')).not.toBeInTheDocument()
expect(view.getByIconName('trash3')).toBeInTheDocument()
})
@@ -213,9 +202,7 @@ describe('CommonActionMenu', () => {
disabled: true,
})
expect(
wrapper.getByRole('button', { name: 'Action menu button' }),
).toBeDisabled()
expect(wrapper.getByRole('button', { name: 'Action menu button' })).toBeDisabled()
await wrapper.rerender({
actions: [actions[0]],

View File

@@ -28,11 +28,7 @@ const sizeClasses = computed(() => {
</script>
<template>
<nav
:class="sizeClasses"
:aria-label="$t('Breadcrumb navigation')"
class="max-w-full"
>
<nav :class="sizeClasses" :aria-label="$t('Breadcrumb navigation')" class="max-w-full">
<ol class="flex">
<li
v-for="(item, idx) in items"
@@ -54,9 +50,7 @@ const sizeClasses = computed(() => {
internal
>
<CommonLabel size="large" class="line-clamp-1 hover:underline">{{
item.noOptionLabelTranslation
? item.label
: $t(item.label as string)
item.noOptionLabelTranslation ? item.label : $t(item.label as string)
}}</CommonLabel>
</CommonLink>
@@ -67,11 +61,7 @@ const sizeClasses = computed(() => {
:class="{ 'text-black dark:text-white': item.isActive }"
aria-current="page"
>
{{
item.noOptionLabelTranslation
? item.label
: $t(item.label as string)
}}
{{ item.noOptionLabelTranslation ? item.label : $t(item.label as string) }}
</component>
<CommonBadge
@@ -85,9 +75,7 @@ const sizeClasses = computed(() => {
<CommonIcon
v-if="idx !== items.length - 1"
:name="
locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'
"
:name="locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'"
size="xs"
class="mx-1 inline-flex shrink-0"
/>

View File

@@ -73,9 +73,7 @@ describe('breadcrumb', () => {
const lastItem = view.getByText('Settings')
expect(lastItem.parentElement).toHaveClass(
'last:dark:text-white last:text-black',
)
expect(lastItem.parentElement).toHaveClass('last:dark:text-white last:text-black')
})
it('supports different text sizes', async () => {
@@ -95,9 +93,7 @@ describe('breadcrumb', () => {
})
// Default size
expect(view.getByLabelText('Breadcrumb navigation')).toHaveClass(
'text-base',
)
expect(view.getByLabelText('Breadcrumb navigation')).toHaveClass('text-base')
await view.rerender({
items: [
@@ -157,8 +153,8 @@ describe('breadcrumb', () => {
router: true,
})
expect(
view.getByRole('heading', { name: 'Settings', level: 1 }),
).toHaveClass('text-black dark:text-white')
expect(view.getByRole('heading', { name: 'Settings', level: 1 })).toHaveClass(
'text-black dark:text-white',
)
})
})

View File

@@ -66,12 +66,7 @@ const variantClasses = computed(() => {
'dark:text-white',
]
case 'neutral':
return [
'bg-transparent',
'hover:bg-transparent',
'text-gray-100',
'dark:text-neutral-400',
]
return ['bg-transparent', 'hover:bg-transparent', 'text-gray-100', 'dark:text-neutral-400']
case 'none':
return []
case 'secondary':

View File

@@ -130,8 +130,6 @@ describe('CommonButton.vue', () => {
expect(view.getByIconName('logo')).toBeInTheDocument()
expect(
view.queryByRole('button', { name: 'foobar' }),
).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'foobar' })).not.toBeInTheDocument()
})
})

View File

@@ -56,15 +56,13 @@ const tableHeaders: TableSimpleHeader[] = [
const tableItems = computed(() => {
if (!calendarEventsQueryResult.value?.calendarIcsFileEvents) return []
return calendarEventsQueryResult.value?.calendarIcsFileEvents.map(
(event) => ({
id: getUuid(),
summary: event.title,
location: event.location,
start: event.startDate,
end: event.endDate,
}),
)
return calendarEventsQueryResult.value?.calendarIcsFileEvents.map((event) => ({
id: getUuid(),
summary: event.title,
location: event.location,
start: event.startDate,
end: event.endDate,
}))
})
const downloadCalendar = () => {

View File

@@ -22,9 +22,7 @@ vi.mock('#shared/utils/openExternalLink.ts', async () => ({
default: vi.fn(),
}))
const { default: openExternalLinkMock } = await import(
'#shared/utils/openExternalLink.ts'
)
const { default: openExternalLinkMock } = await import('#shared/utils/openExternalLink.ts')
const renderCommonCalendarPreviewFlyout = async (
props: Record<string, unknown> = {},
@@ -94,9 +92,7 @@ describe('TicketSidebarSharedDraftFlyout.vue', () => {
}),
).toBeInTheDocument()
expect(
wrapper.getByRole('heading', { name: 'Preview Calendar' }),
).toBeInTheDocument()
expect(wrapper.getByRole('heading', { name: 'Preview Calendar' })).toBeInTheDocument()
expect(wrapper.getByText('event 1')).toBeInTheDocument()
expect(wrapper.getByText('location 1')).toBeInTheDocument()

View File

@@ -42,9 +42,7 @@ const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
headerIcon: 'trash3',
content: __('Are you sure you want to delete this object?'),
footerActionOptions: {
actionLabel:
currentConfirmationOptions.value?.buttonLabel ||
__('Delete Object'),
actionLabel: currentConfirmationOptions.value?.buttonLabel || __('Delete Object'),
actionButton: {
variant: 'danger',
},
@@ -53,9 +51,7 @@ const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
case 'unsaved':
return {
headerTitle: __('Unsaved Changes'),
content: __(
'Are you sure? You have unsaved changes that will get lost.',
),
content: __('Are you sure? You have unsaved changes that will get lost.'),
footerActionOptions: {
actionLabel: __('Discard Changes'),
actionButton: {
@@ -68,11 +64,9 @@ const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
headerTitle: __('Confirmation'),
content: __('Do you want to continue?'),
footerActionOptions: {
actionLabel:
currentConfirmationOptions.value?.buttonLabel || __('Yes'),
actionLabel: currentConfirmationOptions.value?.buttonLabel || __('Yes'),
actionButton: {
variant:
currentConfirmationOptions.value?.buttonVariant || 'primary',
variant: currentConfirmationOptions.value?.buttonVariant || 'primary',
},
cancelLabel: currentConfirmationOptions.value?.cancelLabel,
hideCancelButton: currentConfirmationOptions.value?.hideCancelButton,
@@ -97,9 +91,7 @@ const headerTitle = computed(() => {
<CommonDialog
:name="`confirmation:${props.uniqueId}`"
:header-title="headerTitle"
:header-icon="
currentConfirmationOptions?.headerIcon || confirmationVariant.headerIcon
"
:header-icon="currentConfirmationOptions?.headerIcon || confirmationVariant.headerIcon"
:content="currentConfirmationOptions?.text || confirmationVariant.content"
:content-placeholder="currentConfirmationOptions?.textPlaceholder"
:footer-action-options="confirmationVariant.footerActionOptions"

View File

@@ -69,16 +69,10 @@ describe('dialog confirm behaviour', () => {
await waitForNextTick()
expect(
wrapper.getByRole('dialog', { name: 'Delete Object' }),
).toBeInTheDocument()
expect(
wrapper.getByText('Are you sure you want to delete this object?'),
).toBeInTheDocument()
expect(wrapper.getByRole('dialog', { name: 'Delete Object' })).toBeInTheDocument()
expect(wrapper.getByText('Are you sure you want to delete this object?')).toBeInTheDocument()
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Delete Object' }),
)
await wrapper.events.click(wrapper.getByRole('button', { name: 'Delete Object' }))
expect(confirmCallbackSpy).toHaveBeenCalledTimes(1)
})
@@ -103,12 +97,8 @@ describe('dialog confirm behaviour', () => {
await waitForNextTick()
expect(
wrapper.getByRole('button', { name: 'Custom button title' }),
).toBeInTheDocument()
expect(
wrapper.getByRole('button', { name: 'Custom button title' }),
).toHaveClass('bg-pink-100')
expect(wrapper.getByRole('button', { name: 'Custom button title' })).toBeInTheDocument()
expect(wrapper.getByRole('button', { name: 'Custom button title' })).toHaveClass('bg-pink-100')
})
it('closes the confirmation dialog by using cancel', async () => {
@@ -131,9 +121,7 @@ describe('dialog confirm behaviour', () => {
await waitForNextTick()
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Cancel & Go Back' }),
)
await wrapper.events.click(wrapper.getByRole('button', { name: 'Cancel & Go Back' }))
expect(cancelCallbackSpy).toHaveBeenCalledTimes(1)
})

View File

@@ -37,14 +37,9 @@ export const initializeConfirmationDialog = () => {
if (!lastConfirmationUuid.value) return
if (!confirmationDialogPerRoute.has(getRouteIdentifier(route))) {
confirmationDialogPerRoute.set(
getRouteIdentifier(route),
new Set<string>(),
)
confirmationDialogPerRoute.set(getRouteIdentifier(route), new Set<string>())
}
confirmationDialogPerRoute
.get(getRouteIdentifier(route))!
.add(lastConfirmationUuid.value)
confirmationDialogPerRoute.get(getRouteIdentifier(route))!.add(lastConfirmationUuid.value)
confirmationDialog.open({
uniqueId: lastConfirmationUuid.value,

View File

@@ -58,9 +58,7 @@ const routeIdentifier = getRouteIdentifier(useRoute())
const router = useRouter()
const isActive = computed(() =>
props.fullscreen
? true
: routeIdentifier === getRouteIdentifier(router.currentRoute.value),
props.fullscreen ? true : routeIdentifier === getRouteIdentifier(router.currentRoute.value),
)
const dialogElement = useTemplateRef<HTMLElement>('dialog')
@@ -80,10 +78,7 @@ const escapeConfig: OrderKeyHandlerConfig = {
beforeHandlerRuns: props.escapeConfig?.beforeHandlerRuns,
}
const { unsubscribeEvent, subscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
escapeConfig,
)
const { unsubscribeEvent, subscribeEvent } = useKeyboardEventBus(KeyboardKey.Escape, escapeConfig)
watch(isActive, (isActive) =>
isActive ? subscribeEvent(escapeConfig) : unsubscribeEvent(escapeConfig),
@@ -144,9 +139,7 @@ const transition = VITE_TEST_MODE
data-common-dialog
class="!absolute top-1/2 z-50 flex w-[500px] -translate-y-1/2 flex-col gap-3 rounded-xl border border-neutral-100 bg-neutral-50 p-3 ltr:left-1/2 ltr:-translate-x-1/2 rtl:right-1/2 rtl:translate-x-1/2 dark:border-gray-900 dark:bg-gray-500"
>
<div
class="flex items-center justify-between bg-neutral-50 dark:bg-gray-500"
>
<div class="flex items-center justify-between bg-neutral-50 dark:bg-gray-500">
<slot name="header">
<div
class="flex items-center gap-2 text-xl leading-snug text-gray-100 dark:text-neutral-400"

View File

@@ -35,9 +35,7 @@ const action = () => {
<template>
<!-- TODO: rtl button order? -->
<div
class="flex items-center gap-2 ltr:justify-end rtl:flex-row-reverse rtl:justify-start"
>
<div class="flex items-center gap-2 ltr:justify-end rtl:flex-row-reverse rtl:justify-start">
<CommonButton
v-if="!hideCancelButton"
size="large"

View File

@@ -114,9 +114,7 @@ describe('visuals for common dialog', () => {
router: true,
})
expect(
wrapper.getByRole('button', { name: 'Yes, continue' }),
).toBeInTheDocument()
expect(wrapper.getByRole('button', { name: 'Yes, continue' })).toBeInTheDocument()
})
it('has an accessible name', async () => {
@@ -165,9 +163,7 @@ describe('visuals for common dialog', () => {
expect(wrapper.getByTestId('select')).toHaveFocus()
await wrapper.events.keyboard('{Tab}')
expect(
wrapper.getByRole('button', { name: 'Cancel & Go Back' }),
).toHaveFocus()
expect(wrapper.getByRole('button', { name: 'Cancel & Go Back' })).toHaveFocus()
await wrapper.events.keyboard('{Tab}')
expect(wrapper.getByRole('button', { name: 'OK' })).toHaveFocus()
@@ -221,8 +217,7 @@ describe('visuals for common dialog', () => {
setup() {
const dialog = useDialog({
name: 'dialog',
component: () =>
import('#desktop/components/CommonDialog/CommonDialog.vue'),
component: () => import('#desktop/components/CommonDialog/CommonDialog.vue'),
})
dialog.open({ name: 'dialog', hideFooter: true })
},

View File

@@ -16,8 +16,7 @@ import { getCurrentApp } from '#desktop/currentApp.ts'
const OVERLAY_CONTAINER_TYPE = 'dialog'
export const getOpenedDialogs = () =>
getOpenedOverlayContainers(OVERLAY_CONTAINER_TYPE)
export const getOpenedDialogs = () => getOpenedOverlayContainers(OVERLAY_CONTAINER_TYPE)
export const isDialogOpened = (name?: string) =>
isOverlayContainerOpened(OVERLAY_CONTAINER_TYPE, name)

View File

@@ -71,11 +71,7 @@ const actionItems = computed(() =>
:orientation="orientation"
>
<CommonPopoverMenu v-if="modelValue" :popover="popover" :items="items">
<template
v-for="(item, index) in items"
:key="item.key"
#[`item-${item.key}`]
>
<template v-for="(item, index) in items" :key="item.key" #[`item-${item.key}`]>
<div class="group flex grow cursor-pointer items-center">
<CommonPopoverMenuItem
class="focus-visible-app-default flex grow items-center gap-2 p-2.5 focus-visible:-outline-offset-1!"
@@ -111,8 +107,7 @@ const actionItems = computed(() =>
ref="popoverTarget"
class="group"
:class="{
'hover:bg-blue-600! hover:text-black dark:hover:bg-blue-900! dark:hover:text-white':
!isOpen,
'hover:bg-blue-600! hover:text-black dark:hover:bg-blue-900! dark:hover:text-white': !isOpen,
'bg-blue-800! text-white! outline! outline-offset-1! outline-blue-800! hover:bg-blue-800!':
isOpen,
}"

View File

@@ -24,9 +24,7 @@ describe('CommonDropdown', () => {
},
})
await wrapper.events.click(
wrapper.getByRole('button', { name: 'text-dropdown' }),
)
await wrapper.events.click(wrapper.getByRole('button', { name: 'text-dropdown' }))
expect(await wrapper.findByRole('menu')).toBeInTheDocument()
expect(wrapper.queryByRole('checkbox')).not.toBeInTheDocument()
@@ -40,15 +38,11 @@ describe('CommonDropdown', () => {
},
})
await wrapper.events.click(
wrapper.getByRole('button', { name: 'action-dropdown' }),
)
await wrapper.events.click(wrapper.getByRole('button', { name: 'action-dropdown' }))
expect(await wrapper.findByRole('menu')).toBeInTheDocument()
await wrapper.events.click(
wrapper.getByRole('button', { name: dropdownItems[0].label }),
)
await wrapper.events.click(wrapper.getByRole('button', { name: dropdownItems[0].label }))
expect(wrapper.emitted('handle-action')).toEqual([[dropdownItems[0]]])
})
@@ -66,9 +60,7 @@ describe('CommonDropdown', () => {
},
})
expect(
wrapper.getByRole('button', { name: selectedItem.label }),
).toBeInTheDocument()
expect(wrapper.getByRole('button', { name: selectedItem.label })).toBeInTheDocument()
await wrapper.events.click(wrapper.getByText(dropdownItems[0].label))
@@ -78,8 +70,6 @@ describe('CommonDropdown', () => {
await wrapper.events.click(wrapper.getByText(dropdownItems[1].label))
expect(
await wrapper.findByRole('button', { name: dropdownItems[1].label }),
).toBeInTheDocument()
expect(await wrapper.findByRole('button', { name: dropdownItems[1].label })).toBeInTheDocument()
})
})

View File

@@ -34,11 +34,7 @@ const errorImage = computed(() => {
<CommonLabel class="mx-auto max-w-prose text-center" tag="p">
{{ $t(options?.message, ...(options?.messagePlaceholder || [])) }}
</CommonLabel>
<CommonLabel
v-if="options?.route"
class="mx-auto max-w-prose text-center"
tag="p"
>
<CommonLabel v-if="options?.route" class="mx-auto max-w-prose text-center" tag="p">
{{ options.route }}
</CommonLabel>
<CommonLink v-if="!authenticated" link="/login" size="medium">

View File

@@ -105,9 +105,7 @@ const routeIdentifier = getRouteIdentifier(useRoute())
const router = useRouter()
const isActive = computed(() =>
props.fullscreen
? true
: routeIdentifier === getRouteIdentifier(router.currentRoute.value),
props.fullscreen ? true : routeIdentifier === getRouteIdentifier(router.currentRoute.value),
)
whenever(isActive, () => {
@@ -130,11 +128,7 @@ const close = async (isCancel?: boolean) => {
dialogName = `${dialogName}_${routeIdentifier}`
}
const confirmed = await waitForVariantConfirmation(
'unsaved',
undefined,
dialogName,
)
const confirmed = await waitForVariantConfirmation('unsaved', undefined, dialogName)
if (!confirmed) return
}
@@ -171,27 +165,18 @@ let flyoutContainerWidth: Ref<number>
const gap = 16 // Gap between sidebar and flyout
const storageKeys = Object.keys(localStorage).filter((key) =>
key.includes('sidebar-width'),
)
const storageKeys = Object.keys(localStorage).filter((key) => key.includes('sidebar-width'))
const leftSideBarKey = storageKeys.find((key) => key.includes('left'))
const leftSidebarWidth = leftSideBarKey
? useLocalStorage(leftSideBarKey, 0)
: shallowRef(0)
const leftSidebarWidth = leftSideBarKey ? useLocalStorage(leftSideBarKey, 0) : shallowRef(0)
const { width: screenWidth } = useWindowSize()
// Calculate the viewport width minus the left sidebar width and a threshold gap
const flyoutMaxWidth = computed(
() => screenWidth.value - leftSidebarWidth.value - gap,
)
const flyoutMaxWidth = computed(() => screenWidth.value - leftSidebarWidth.value - gap)
if (props.persistResizeWidth) {
flyoutContainerWidth = useLocalStorage(
`${flyoutId}-width`,
flyoutSize[props.size || 'medium'],
)
flyoutContainerWidth = useLocalStorage(`${flyoutId}-width`, flyoutSize[props.size || 'medium'])
} else {
flyoutContainerWidth = ref(flyoutSize[props.size || 'medium'])
}
@@ -207,10 +192,7 @@ const resizeCallback = (valueX: number) => {
const activeElement = useActiveElement()
const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
if (
!flyoutContainerWidth.value ||
activeElement.value !== resizeHandleInstance.value?.resizeLine
)
if (!flyoutContainerWidth.value || activeElement.value !== resizeHandleInstance.value?.resizeLine)
return
e.preventDefault()
@@ -264,10 +246,7 @@ const escapeConfig: OrderKeyHandlerConfig = {
beforeHandlerRuns: props.escapeConfig?.beforeHandlerRuns,
}
const { subscribeEvent, unsubscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
escapeConfig,
)
const { subscribeEvent, unsubscribeEvent } = useKeyboardEventBus(KeyboardKey.Escape, escapeConfig)
watch(isActive, (isActive) =>
isActive ? subscribeEvent(escapeConfig) : unsubscribeEvent(escapeConfig),
@@ -288,10 +267,7 @@ watch(
// Watch if panel gets resized to show and hide styling based on content overflow
await nextTick()
if (
contentElement.value?.scrollHeight &&
contentElement.value?.clientHeight
) {
if (contentElement.value?.scrollHeight && contentElement.value?.clientHeight) {
isContentOverflowing.value =
contentElement.value.scrollHeight > contentElement.value.clientHeight
}
@@ -348,8 +324,7 @@ const transition = VITE_TEST_MODE
ref="header"
class="sticky top-0 flex items-center border-b border-neutral-100 border-b-transparent bg-neutral-50 p-3 ltr:rounded-tl-xl rtl:rounded-tr-xl dark:bg-gray-500"
:class="{
'border-b-neutral-100 dark:border-b-gray-900':
!arrivedState.top && isContentOverflowing,
'border-b-neutral-100 dark:border-b-gray-900': !arrivedState.top && isContentOverflowing,
}"
>
<slot name="header">

View File

@@ -81,9 +81,7 @@ describe('CommonFlyout', () => {
resizable: false,
})
expect(
flyout.queryByLabelText('Resize side panel'),
).not.toBeInTheDocument()
expect(flyout.queryByLabelText('Resize side panel')).not.toBeInTheDocument()
})
it('renders slot content', async () => {
@@ -115,11 +113,7 @@ describe('CommonFlyout', () => {
showBackdrop: false,
},
slots: {
default: html`<input
type="text"
placeholder="test"
name="test-input"
/>`,
default: html`<input type="text" placeholder="test" name="test-input" />`,
},
})

View File

@@ -7,10 +7,7 @@ export type FlyoutSizes = 'medium' | 'large'
export interface ActionFooterOptions {
hideActionButton?: boolean
actionLabel?: string
actionButton?: Pick<
ButtonProps,
'prefixIcon' | 'variant' | 'type' | 'disabled'
>
actionButton?: Pick<ButtonProps, 'prefixIcon' | 'variant' | 'type' | 'disabled'>
hideCancelButton?: boolean
cancelLabel?: string
cancelButton?: Pick<ButtonProps, 'prefixIcon' | 'variant' | 'disabled'>

View File

@@ -16,8 +16,7 @@ import { getCurrentApp } from '#desktop/currentApp.ts'
const OVERLAY_CONTAINER_TYPE = 'flyout'
export const getOpenedFlyouts = () =>
getOpenedOverlayContainers(OVERLAY_CONTAINER_TYPE)
export const getOpenedFlyouts = () => getOpenedOverlayContainers(OVERLAY_CONTAINER_TYPE)
export const isFlyoutOpened = (name?: string) =>
isOverlayContainerOpened(OVERLAY_CONTAINER_TYPE, name)

View File

@@ -42,9 +42,7 @@ export interface Props {
label?: string
input?: string
}
onSubmitEdit?: (
value: string,
) => Promise<void | (() => void)> | void | (() => void)
onSubmitEdit?: (value: string) => Promise<void | (() => void)> | void | (() => void)
}
const props = withDefaults(defineProps<Props>(), {
@@ -101,8 +99,7 @@ const stopEditing = (emitCancel = true) => {
isEditing.value = false
if (emitCancel) emit('cancel-edit')
if (!newEditValue.value.length)
newEditValue.value = props.initialEditValue ?? props.value
if (!newEditValue.value.length) newEditValue.value = props.initialEditValue ?? props.value
}
const activateEditing = (event?: MouseEvent | KeyboardEvent) => {
@@ -176,8 +173,7 @@ useTrapTab(target)
watch(
() => props.value,
() => {
if (props.detectLinks && labelInstance.value?.$el)
setupLinksHandlers(labelInstance.value?.$el)
if (props.detectLinks && labelInstance.value?.$el) setupLinksHandlers(labelInstance.value?.$el)
},
{
flush: 'post',
@@ -186,8 +182,7 @@ watch(
onMounted(() => {
nextTick(() => {
if (props.detectLinks && labelInstance.value?.$el)
setupLinksHandlers(labelInstance.value?.$el)
if (props.detectLinks && labelInstance.value?.$el) setupLinksHandlers(labelInstance.value?.$el)
})
})
@@ -212,11 +207,9 @@ const focusClasses = computed(() => {
'group-focus-within:before:absolute group-focus-within:before:-left-[5px] group-focus-within:before:top-1/2 group-focus-within:before:z-0 group-focus-within:before:h-[calc(100%+10px)] group-focus-within:before:w-[calc(100%+10px)] group-focus-within:before:-translate-y-1/2 group-focus-within:before:rounded-md'
if (props.alternativeBackground) {
classes +=
' group-focus-within:before:bg-neutral-50 dark:group-focus-within:before:bg-gray-500'
classes += ' group-focus-within:before:bg-neutral-50 dark:group-focus-within:before:bg-gray-500'
} else {
classes +=
' group-focus-within:before:bg-blue-200 dark:group-focus-within:before:bg-gray-700'
classes += ' group-focus-within:before:bg-blue-200 dark:group-focus-within:before:bg-gray-700'
}
return classes
})
@@ -319,11 +312,7 @@ defineExpose({
<div
v-else
class="flex max-w-full items-center gap-2 before:absolute before:top-1/2 before:-left-[5px] before:z-0 before:h-[calc(100%+10px)] before:w-[calc(100%+10px)] before:-translate-y-1/2 before:rounded-md"
:class="[
{ 'w-full': block },
editBackgroundClass,
fontSizeClassMap[size],
]"
:class="[{ 'w-full': block }, editBackgroundClass, fontSizeClassMap[size]]"
>
<div class="relative z-10 w-full ltr:pr-14 rtl:pl-14">
<input

View File

@@ -35,9 +35,7 @@ describe('CommonInlineEdit', () => {
const wrapper = renderInlineEdit({ placeholder: 'test placeholder' })
await wrapper.events.click(wrapper.getByRole('button'))
expect(
await wrapper.findByPlaceholderText('test placeholder'),
).toBeInTheDocument()
expect(await wrapper.findByPlaceholderText('test placeholder')).toBeInTheDocument()
})
it('submits edit on button click and enter', async () => {
@@ -51,9 +49,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.type(wrapper.getByRole('textbox'), ' update')
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await wrapper.events.click(wrapper.getByRole('button', { name: 'Submit' }))
@@ -73,9 +69,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.type(wrapper.getByRole('textbox'), ' update 2')
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await wrapper.events.keyboard('{enter}')
@@ -93,9 +87,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.type(wrapper.getByRole('textbox'), ' update 2')
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await fireEvent.click(document.body)
@@ -115,9 +107,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.type(wrapper.getByRole('textbox'), ' update')
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await wrapper.events.click(wrapper.getByRole('button', { name: 'Submit' }))
@@ -129,9 +119,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.click(wrapper.getByRole('button'))
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
expect(wrapper.getByRole('textbox')).toHaveFocus()
})
@@ -153,9 +141,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.click(wrapper.getByRole('button'))
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await wrapper.events.keyboard('{esc}')
@@ -167,9 +153,7 @@ describe('CommonInlineEdit', () => {
await wrapper.events.click(wrapper.getByRole('button'))
await waitFor(() =>
expect(wrapper.getByRole('textbox')).toBeInTheDocument(),
)
await waitFor(() => expect(wrapper.getByRole('textbox')).toBeInTheDocument())
await wrapper.events.clear(wrapper.getByRole('textbox'))
@@ -214,9 +198,7 @@ describe('CommonInlineEdit', () => {
value: 'https://zammad.com/en',
})
expect(
wrapper.getByRole('link', { name: 'https://zammad.com/en' }),
).toBeInTheDocument()
expect(wrapper.getByRole('link', { name: 'https://zammad.com/en' })).toBeInTheDocument()
})
it('displays initial edit value if editing got activated', async () => {
@@ -249,20 +231,14 @@ describe('CommonInlineEdit', () => {
await wrapper.events.click(wrapper.getByText('test value'))
expect(wrapper.html()).toContain(
'before:bg-neutral-50 dark:before:bg-gray-500',
)
expect(wrapper.html()).toContain('before:bg-neutral-50 dark:before:bg-gray-500')
await wrapper.rerender({
alternativeBackground: false,
})
expect(wrapper.html()).toContain(
'before:bg-blue-200 dark:before:bg-gray-700',
)
expect(wrapper.html()).toContain('before:bg-blue-200 dark:before:bg-gray-700')
expect(wrapper.html()).not.toContain(
'before:bg-neutral-50 dark:before:bg-gray-500',
)
expect(wrapper.html()).not.toContain('before:bg-neutral-50 dark:before:bg-gray-500')
})
})

View File

@@ -9,8 +9,7 @@ import CommonInputCopyToClipboard from '../CommonInputCopyToClipboard.vue'
const clipboardCopyMock = vi.fn()
vi.mock('@vueuse/core', async () => {
const mod =
await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core')
const mod = await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core')
return {
...mod,
@@ -21,10 +20,7 @@ vi.mock('@vueuse/core', async () => {
}
})
const renderCopyToClipboard = (
props: Record<string, unknown> = {},
options: any = {},
) => {
const renderCopyToClipboard = (props: Record<string, unknown> = {}, options: any = {}) => {
return renderComponent(CommonInputCopyToClipboard, {
props,
...options,

View File

@@ -46,9 +46,7 @@ const clearFilter = () => {
focus()
}
const suggestionVisiblePart = computed(() =>
props.suggestion?.slice(filter.value?.length),
)
const suggestionVisiblePart = computed(() => props.suggestion?.slice(filter.value?.length))
const maybeAcceptSuggestion = (event: Event) => {
if (
@@ -69,10 +67,7 @@ const onKeydown = (event: KeyboardEvent) => emit('keydown', event)
</script>
<template>
<div
class="inline-flex grow items-center justify-start gap-1 text-sm"
:class="wrapperClass"
>
<div class="inline-flex grow items-center justify-start gap-1 text-sm" :class="wrapperClass">
<CommonIcon
class="shrink-0 fill-stone-200 dark:fill-neutral-500"
size="tiny"
@@ -109,9 +104,7 @@ const onKeydown = (event: KeyboardEvent) => emit('keydown', event)
data-test-id="suggestion"
>
<span class="invisible">{{ filter }}</span>
<span class="text-stone-200 dark:text-neutral-500">{{
suggestionVisiblePart
}}</span>
<span class="text-stone-200 dark:text-neutral-500">{{ suggestionVisiblePart }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">

View File

@@ -4,9 +4,7 @@ import { onMounted, ref } from 'vue'
import { renderComponent } from '#tests/support/components/index.ts'
import CommonInputSearch, {
type CommonInputSearchExpose,
} from '../CommonInputSearch.vue'
import CommonInputSearch, { type CommonInputSearchExpose } from '../CommonInputSearch.vue'
describe('testing input for searching', () => {
it('renders input', async () => {

View File

@@ -1,8 +1,6 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
/* eslint-disable vue/no-v-html */
import { computed } from 'vue'
import type { Sizes } from '#shared/components/CommonIcon/types.ts'
@@ -64,6 +62,7 @@ export default {
/>
</div>
<CommonAlert v-else-if="error" v-bind="$attrs" variant="danger">
<!-- eslint-disable vue/no-v-html -->
<span v-html="markup($t(error))" />
</CommonAlert>
<slot v-else />

View File

@@ -15,11 +15,7 @@ defineProps<Props>()
{{ $t(label) }}
</CommonLabel>
<CommonLabel
tag="div"
size="medium"
class="break-word text-gray-100! dark:text-neutral-400!"
>
<CommonLabel tag="div" size="medium" class="break-word text-gray-100! dark:text-neutral-400!">
<slot>{{ body }}</slot>
</CommonLabel>
</div>

View File

@@ -11,12 +11,9 @@ defineProps<Props>()
<template>
<div class="ltr:text-left rtl:text-right">
<template v-if="Array.isArray(helpText)">
<CommonLabel
v-for="(text, index) in helpText"
:key="`${text}-${index}`"
tag="p"
>{{ text }}</CommonLabel
>
<CommonLabel v-for="(text, index) in helpText" :key="`${text}-${index}`" tag="p">{{
text
}}</CommonLabel>
</template>
<CommonLabel v-else tag="p">{{ helpText }}</CommonLabel>
</div>

View File

@@ -1,12 +1,7 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import {
onClickOutside,
onLongPress,
useElementHover,
whenever,
} from '@vueuse/core'
import { onClickOutside, onLongPress, useElementHover, whenever } from '@vueuse/core'
import { computed, onDeactivated, onUnmounted, shallowRef, watch } from 'vue'
import CommonPopover, {
@@ -64,23 +59,20 @@ const isPopoverTargetHovered = useElementHover(popoverTarget, {
delayLeave: 200,
})
watch(
[isPopoverHovered, isPopoverTargetHovered],
([isPopoverHovered, isPopoverTargetHovered]) => {
if (hasOpenedViaLongPress.value) return
watch([isPopoverHovered, isPopoverTargetHovered], ([isPopoverHovered, isPopoverTargetHovered]) => {
if (hasOpenedViaLongPress.value) return
const shouldOpen = isPopoverTargetHovered || isPopoverHovered
const shouldOpen = isPopoverTargetHovered || isPopoverHovered
if (shouldOpen && !isOpen.value) {
open()
return
}
if (shouldOpen && !isOpen.value) {
open()
return
}
if (!shouldOpen && isOpen.value) {
close()
}
},
)
if (!shouldOpen && isOpen.value) {
close()
}
})
whenever(
() => !isOpen.value,

View File

@@ -70,16 +70,9 @@ const getHoverFocusStyles = (variant?: Variant) => {
<template>
<section class="flex max-w-64 min-w-58 flex-col gap-0.5">
<div
v-if="showHeaderLabel"
role="heading"
aria-level="2"
class="px-2 py-1.5"
>
<div v-if="showHeaderLabel" role="heading" aria-level="2" class="px-2 py-1.5">
<slot name="header">
<CommonLabel
size="small"
class="line-clamp-1 text-stone-200! dark:text-neutral-500!"
<CommonLabel size="small" class="line-clamp-1 text-stone-200! dark:text-neutral-500!"
>{{ i18n.t(headerLabel) }}
</CommonLabel>
</slot>
@@ -87,12 +80,7 @@ const getHoverFocusStyles = (variant?: Variant) => {
<template v-if="filteredMenuItems || $slots.default">
<slot>
<ul
v-if="filteredMenuItems"
role="menu"
v-bind="$attrs"
class="flex w-full flex-col"
>
<ul v-if="filteredMenuItems" role="menu" v-bind="$attrs" class="flex w-full flex-col">
<template v-for="(item, index) in filteredMenuItems" :key="item.key">
<li
v-if="'array' in item"
@@ -114,8 +102,7 @@ const getHoverFocusStyles = (variant?: Variant) => {
:class="[
getHoverFocusStyles(subItem.variant),
{
'last:rounded-b-xl':
index === filteredMenuItems?.length - 1,
'last:rounded-b-xl': index === filteredMenuItems?.length - 1,
},
]"
:label="subItem.label"
@@ -137,8 +124,7 @@ const getHoverFocusStyles = (variant?: Variant) => {
:class="[
{
'first:rounded-t-[10px]': !showHeaderLabel,
'border-t border-neutral-100 dark:border-gray-900':
item.separatorTop,
'border-t border-neutral-100 dark:border-gray-900': item.separatorTop,
},
getHoverFocusStyles(item.variant),
]"

View File

@@ -101,9 +101,7 @@ describe('rendering section', () => {
it('support click handler on item', async () => {
const clickHandler = vi.fn()
const items: MenuItem[] = [
{ key: 'example', onClick: clickHandler, label: 'Example' },
]
const items: MenuItem[] = [{ key: 'example', onClick: clickHandler, label: 'Example' }]
const view = renderComponent(CommonPopoverMenu, {
shallow: false,
@@ -176,9 +174,7 @@ describe('rendering section', () => {
}
CustomComponent.props = ['label']
const items: MenuItem[] = [
{ key: 'menu-item', component: CustomComponent, label: 'Menu item' },
]
const items: MenuItem[] = [{ key: 'menu-item', component: CustomComponent, label: 'Menu item' }]
const view = renderComponent(CommonPopoverMenu, {
shallow: false,
@@ -249,25 +245,13 @@ describe('rendering section', () => {
})
expect(view.getByRole('button', { name: 'group test' })).toBeInTheDocument()
expect(
view.getByRole('button', { name: 'group test 2' }),
).toBeInTheDocument()
expect(
view.getByRole('button', { name: 'group test 3' }),
).toBeInTheDocument()
expect(
view.getByRole('button', { name: 'single-group test 3' }),
).toBeInTheDocument()
expect(
view.getByRole('button', { name: 'single test' }),
).toBeInTheDocument()
expect(view.getByRole('button', { name: 'group test 2' })).toBeInTheDocument()
expect(view.getByRole('button', { name: 'group test 3' })).toBeInTheDocument()
expect(view.getByRole('button', { name: 'single-group test 3' })).toBeInTheDocument()
expect(view.getByRole('button', { name: 'single test' })).toBeInTheDocument()
expect(
view.getByRole('heading', { name: 'test group', level: 3 }),
).toBeInTheDocument()
expect(view.getByRole('heading', { name: 'test group', level: 3 })).toBeInTheDocument()
expect(
view.getByRole('heading', { name: 'single group', level: 3 }),
).toBeInTheDocument()
expect(view.getByRole('heading', { name: 'single group', level: 3 })).toBeInTheDocument()
})
})

View File

@@ -6,12 +6,7 @@ import { useSessionStore } from '#shared/stores/session.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import getUuid from '#shared/utils/getUuid.ts'
import type {
GroupItem,
MenuItem,
MenuItems,
UsePopoverMenuReturn,
} from './types.ts'
import type { GroupItem, MenuItem, MenuItems, UsePopoverMenuReturn } from './types.ts'
import type { Ref } from 'vue'
const POPOVER_MENU_SYMBOL = Symbol('popover-menu')
@@ -21,10 +16,7 @@ export const usePopoverMenu = (
entity: Ref<ObjectLike | undefined>,
options: { provides?: boolean } = {},
) => {
const injectPopoverMenu = inject<Maybe<UsePopoverMenuReturn>>(
POPOVER_MENU_SYMBOL,
null,
)
const injectPopoverMenu = inject<Maybe<UsePopoverMenuReturn>>(POPOVER_MENU_SYMBOL, null)
if (injectPopoverMenu) return injectPopoverMenu
@@ -35,9 +27,7 @@ export const usePopoverMenu = (
const filterItems = () => {
return items.value?.filter((item) => {
if (item.permission && item.show) {
return (
session.hasPermission(item.permission) && item.show(entity?.value)
)
return session.hasPermission(item.permission) && item.show(entity?.value)
}
if (item.permission) {
@@ -62,9 +52,7 @@ export const usePopoverMenu = (
return acc
}
const foundedItem = acc.find(
(group) => group.groupLabel === item.groupLabel,
)
const foundedItem = acc.find((group) => group.groupLabel === item.groupLabel)
const { groupLabel, ...rest } = item

View File

@@ -12,10 +12,7 @@ const { links } = usePublicLinks(props.screen)
</script>
<template>
<nav
v-if="links.length"
class="inline-flex flex-wrap items-center justify-center gap-x-2 py-3"
>
<nav v-if="links.length" class="inline-flex flex-wrap items-center justify-center gap-x-2 py-3">
<template v-for="link in links" :key="link.id">
<CommonLink
:link="link.link"

View File

@@ -64,10 +64,7 @@ watch(
<template>
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions-->
<div
class="flex flex-col gap-1"
:class="{ 'overflow-y-auto outline-none': scrollable }"
>
<div class="flex flex-col gap-1" :class="{ 'overflow-y-auto outline-none': scrollable }">
<header
v-if="!noHeader"
:id="headerId"
@@ -84,11 +81,7 @@ watch(
@keydown.enter="!noCollapse && toggleCollapse()"
>
<slot name="title">
<CommonLabel
class="grow text-current! select-none"
:size="size"
tag="h3"
>
<CommonLabel class="grow text-current! select-none" :size="size" tag="h3">
{{ $t(title) }}
</CommonLabel>
</slot>

View File

@@ -1,22 +1,9 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import {
type UseElementBoundingReturn,
onClickOutside,
onKeyDown,
useVModel,
} from '@vueuse/core'
import { type UseElementBoundingReturn, onClickOutside, onKeyDown, useVModel } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import {
computed,
type ConcreteComponent,
nextTick,
onUnmounted,
ref,
type Ref,
toRef,
} from 'vue'
import { computed, type ConcreteComponent, nextTick, onUnmounted, ref, type Ref, toRef } from 'vue'
import type {
MatchedSelectOption,
@@ -38,17 +25,10 @@ import { useTransitionCollapse } from '#desktop/composables/useTransitionCollaps
import CommonSelectItem from './CommonSelectItem.vue'
import { useCommonSelect } from './useCommonSelect.ts'
import type {
CommonSelectInternalInstance,
DropdownOptionsAction,
} from './types.ts'
import type { CommonSelectInternalInstance, DropdownOptionsAction } from './types.ts'
export interface Props {
modelValue?:
| SelectValue
| SelectValue[]
| { value: SelectValue; label: string }
| null
modelValue?: SelectValue | SelectValue[] | { value: SelectValue; label: string } | null
options: AutoCompleteOption[] | SelectOption[]
/**
* Do not modify local value
@@ -91,9 +71,7 @@ if (localValue.value == null && props.multiple) {
}
const getFocusableOptions = () => {
return Array.from<HTMLElement>(
dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
)
return Array.from<HTMLElement>(dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [])
}
const showDropdown = ref(false)
@@ -118,9 +96,7 @@ const dropdownStyle = computed(() => {
if (hasDirectionUp.value) {
style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
} else {
style.top = `${
inputElementBounds.top.value + inputElementBounds.height.value
}px`
style.top = `${inputElementBounds.top.value + inputElementBounds.height.value}px`
}
return style
@@ -147,16 +123,12 @@ const closeDropdown = (refocusOnEscape?: boolean) => {
emit('close')
nextTick(() => {
if (!props.noRefocus || refocusOnEscape)
lastFocusableOutsideElement?.focus()
if (!props.noRefocus || refocusOnEscape) lastFocusableOutsideElement?.focus()
testFlags.set('common-select.closed')
})
}
const openDropdown = (
bounds: UseElementBoundingReturn,
height: Ref<number>,
) => {
const openDropdown = (bounds: UseElementBoundingReturn, height: Ref<number>) => {
inputElementBounds = bounds
windowHeight = toRef(height)
instances.value.forEach((instance) => {
@@ -187,9 +159,7 @@ const moveFocusToDropdown = (lastOption = false) => {
if (lastOption) {
focusElement = focusableElements[focusableElements.length - 1]
} else {
const selected = focusableElements.find(
(el) => el.getAttribute('aria-selected') === 'true',
)
const selected = focusableElements.find((el) => el.getAttribute('aria-selected') === 'true')
if (selected) focusElement = selected
}
@@ -271,9 +241,7 @@ const select = (option: SelectOption) => {
const hasMoreSelectableOptions = computed(
() =>
props.options.filter(
(option) => !option.disabled && !isCurrentValue(option.value),
).length > 0,
props.options.filter((option) => !option.disabled && !isCurrentValue(option.value)).length > 0,
)
const focusFirstOption = () => {
@@ -316,9 +284,7 @@ const highlightedOptions = computed(() =>
option.match.index + option.match[0].length,
)
const labelAfterMatch = label.slice(
option.match.index + option.match[0].length,
)
const labelAfterMatch = label.slice(option.match.index + option.match[0].length)
const highlightClasses = option.disabled
? 'bg-blue-200 dark:bg-gray-300'
@@ -386,13 +352,7 @@ const childPageCallback = (option?: AutoCompleteOption, noFocus?: boolean) => {
}
}
const goToChildPage = ({
option,
noFocus,
}: {
option: AutoCompleteOption
noFocus?: boolean
}) => {
const goToChildPage = ({ option, noFocus }: { option: AutoCompleteOption; noFocus?: boolean }) => {
childPageCallback(option, noFocus)
}
</script>
@@ -436,11 +396,7 @@ const goToChildPage = ({
v-if="isChildPage"
class="text-blue-800! hover:text-black! focus-visible:rounded-xs focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:text-blue-800! dark:hover:text-white!"
:aria-label="$t('Back to previous page')"
:prefix-icon="
locale.localeData?.dir === 'rtl'
? 'chevron-right'
: 'chevron-left'
"
:prefix-icon="locale.localeData?.dir === 'rtl' ? 'chevron-right' : 'chevron-left'"
size="small"
role="button"
tabindex="0"
@@ -450,10 +406,7 @@ const goToChildPage = ({
>
{{ $t('Back') }}
</CommonLabel>
<div
v-if="dropdownActions.length"
class="flex grow justify-end gap-2"
>
<div v-if="dropdownActions.length" class="flex grow justify-end gap-2">
<CommonLabel
v-for="action of dropdownActions"
:key="action.key"
@@ -484,9 +437,7 @@ const goToChildPage = ({
:key="String(option.value)"
:class="{
'first:rounded-t-lg':
hasDirectionUp &&
!isChildPage &&
(!multiple || !hasMoreSelectableOptions),
hasDirectionUp && !isChildPage && (!multiple || !hasMoreSelectableOptions),
'last:rounded-b-lg': !hasDirectionUp,
}"
:selected="isCurrentValue(option.value)"

View File

@@ -1,13 +1,9 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
/* eslint-disable vue/no-v-html */
import { computed, type ConcreteComponent } from 'vue'
import type {
MatchedSelectOption,
SelectOption,
} from '#shared/components/CommonSelect/types.ts'
import type { MatchedSelectOption, SelectOption } from '#shared/components/CommonSelect/types.ts'
import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types'
import { i18n } from '#shared/i18n.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
@@ -40,19 +36,13 @@ const label = computed(() => {
if (props.noLabelTranslate && !option.labelPlaceholder)
return option.label || option.value.toString()
return (
i18n.t(option.label, ...(option.labelPlaceholder || [])) ||
option.value.toString()
)
return i18n.t(option.label, ...(option.labelPlaceholder || [])) || option.value.toString()
})
const heading = computed(() => {
const { option } = props
if (
props.noLabelTranslate &&
!(option as AutoCompleteOption).headingPlaceholder
)
if (props.noLabelTranslate && !(option as AutoCompleteOption).headingPlaceholder)
return (option as AutoCompleteOption).heading
return i18n.t(
@@ -120,11 +110,8 @@ const goToNextPage = (option: AutoCompleteOption, noFocus?: boolean) => {
decorative
class="shrink-0 fill-gray-100 group-hover:fill-black group-focus:fill-white dark:fill-neutral-400 dark:group-hover:fill-white"
/>
<div
v-if="filter"
v-tooltip="label + (heading ? ` – ${heading}` : '')"
class="grow truncate"
>
<div v-if="filter" v-tooltip="label + (heading ? ` – ${heading}` : '')" class="grow truncate">
<!-- eslint-disable vue/no-v-html -->
<span
:class="{
'text-stone-200 dark:text-neutral-500':
@@ -179,9 +166,7 @@ const goToNextPage = (option: AutoCompleteOption, noFocus?: boolean) => {
!option.disabled,
}"
class="shrink-0 fill-stone-200 group-hover/nav:!fill-white dark:fill-neutral-500"
:name="
locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'
"
:name="locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'"
size="xs"
tabindex="-1"
decorative

View File

@@ -55,25 +55,20 @@ describe('CommonSelect.vue', () => {
const view = renderSelect({ options }, modelValue)
await view.events.click(view.getByText('Open Select'))
expect(view.queryByRole('menu')).toBeInTheDocument()
await view.events.click(view.getByText('Item A'))
expect(view.emitted().select).toEqual([[options[0]]])
expect(
view.queryByRole('menu'),
'dropdown is hidden',
).not.toBeInTheDocument()
expect(view.queryByRole('menu')).not.toBeInTheDocument()
expect(modelValue.value).toBe(0)
await view.events.click(view.getByText('Open Select'))
expect(
view.getByIconName((name, node) => {
return (
name === '#icon-check2' &&
!node?.parentElement?.classList.contains('invisible')
)
return name === '#icon-check2' && !node?.parentElement?.classList.contains('invisible')
}),
).toBeInTheDocument()
@@ -165,10 +160,7 @@ describe('CommonSelect.vue', () => {
it('cannot select disabled values', async () => {
const modelValue = ref()
const view = renderSelect(
{ options: [{ ...options[0], disabled: true }] },
modelValue,
)
const view = renderSelect({ options: [{ ...options[0], disabled: true }] }, modelValue)
await view.events.click(view.getByText('Open Select'))
@@ -210,9 +202,7 @@ describe('CommonSelect.vue', () => {
const view = renderSelect({ options, noOptionsLabelTranslation: true })
await view.events.click(view.getByText('Open Select'))
expect(
view.getByRole('option', { name: 'Label (A) – Heading (B)' }),
).toBeInTheDocument()
expect(view.getByRole('option', { name: 'Label (A) – Heading (B)' })).toBeInTheDocument()
})
it('can use boolean as value', async () => {
@@ -257,10 +247,7 @@ describe('CommonSelect.vue', () => {
const option = view.getByRole('option')
expect(option).toHaveTextContent('foo (1) – bar (2)')
expect(option.children[1]).toHaveAttribute(
'aria-label',
'foo (1) – bar (2)',
)
expect(option.children[1]).toHaveAttribute('aria-label', 'foo (1) – bar (2)')
})
it('supports navigating options with children', async () => {
@@ -282,9 +269,7 @@ describe('CommonSelect.vue', () => {
await view.events.click(view.getByText('Open Select'))
expect(
view.queryByRole('button', { name: 'Back to previous page' }),
).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Back to previous page' })).not.toBeInTheDocument()
expect(view.getByRole('option')).toHaveTextContent('parent')
expect(view.getByRole('option')).toHaveAttribute('aria-disabled', 'true')
@@ -300,9 +285,7 @@ describe('CommonSelect.vue', () => {
expect(view.getByRole('option')).toHaveTextContent('child')
await view.events.click(
view.getByRole('button', { name: 'Back to previous page' }),
)
await view.events.click(view.getByRole('button', { name: 'Back to previous page' }))
expect(view.emitted().pop).toEqual([[]])
@@ -311,9 +294,7 @@ describe('CommonSelect.vue', () => {
isChildPage: false,
})
expect(
view.queryByRole('button', { name: 'Back to previous page' }),
).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Back to previous page' })).not.toBeInTheDocument()
expect(view.getByRole('option')).toHaveTextContent('parent')
expect(view.getByRole('option')).toHaveAttribute('aria-disabled', 'true')

View File

@@ -11,8 +11,7 @@ export interface CommonSelectInstance {
isOpen: boolean
}
export interface CommonSelectInternalInstance
extends Omit<CommonSelectInstance, 'isOpen'> {
export interface CommonSelectInternalInstance extends Omit<CommonSelectInstance, 'isOpen'> {
isOpen: Ref<boolean>
}

View File

@@ -5,9 +5,9 @@ import { computed, ref } from 'vue'
import type { CommonSelectInternalInstance } from './types.ts'
import type { ComputedRef } from 'vue'
const instances = ref(
new Set<CommonSelectInternalInstance>(),
) as unknown as ComputedRef<Set<CommonSelectInternalInstance>>
const instances = ref(new Set<CommonSelectInternalInstance>()) as unknown as ComputedRef<
Set<CommonSelectInternalInstance>
>
export const useCommonSelect = () => {
const isOpened = computed(() => {

View File

@@ -13,11 +13,7 @@ defineProps<Props>()
</script>
<template>
<CommonButton
v-if="entities.length < totalCount"
type="button"
:disabled="disabled"
>
<CommonButton v-if="entities.length < totalCount" type="button" :disabled="disabled">
{{ $t('Show %s more', totalCount - entities.length) }}
</CommonButton>
</template>

View File

@@ -8,10 +8,7 @@ import type { ObjectLike } from '#shared/types/utils.ts'
import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
import CommonShowMoreButton from '#desktop/components/CommonShowMoreButton/CommonShowMoreButton.vue'
import entityModules from '#desktop/components/CommonSimpleEntityList/plugins/index.ts'
import {
type Entity,
EntityType,
} from '#desktop/components/CommonSimpleEntityList/types.ts'
import { type Entity, EntityType } from '#desktop/components/CommonSimpleEntityList/types.ts'
interface Props {
id: string
@@ -44,19 +41,10 @@ const entitySetup = computed(() => {
</script>
<template>
<CommonSectionCollapse
:id="id"
v-model="modelValue"
:title="label"
:no-header="!label"
>
<CommonSectionCollapse :id="id" v-model="modelValue" :title="label" :no-header="!label">
<ul v-if="entity.array?.length" class="flex flex-col gap-1.5">
<li v-for="item in entitySetup.array" :key="`entity-${item.id}`">
<component
:is="entitySetup.component"
:entity="item"
:context="entitySetup.context"
/>
<component :is="entitySetup.component" :entity="item" :context="entitySetup.context" />
</li>
</ul>
@@ -64,12 +52,7 @@ const entitySetup = computed(() => {
>{{ entitySetup.context.emptyMessage }}
</CommonLabel>
<slot
v-if="entity"
name="trailing"
:total-count="entity.totalCount"
:entities="entity.array"
>
<slot v-if="entity" name="trailing" :total-count="entity.totalCount" :entities="entity.array">
<CommonShowMoreButton
class="self-end"
:entities="entity.array"

View File

@@ -33,17 +33,10 @@ describe('CommonSimpleEntityList', () => {
expect(await wrapper.findByText('Nicole Braun')).toBeInTheDocument()
expect(wrapper.getByText('Thomas Ernst')).toBeInTheDocument()
expect(
wrapper.getByLabelText('Avatar (Nicole Braun)'),
).toBeInTheDocument()
expect(
wrapper.getByLabelText('Avatar (Thomas Ernst)'),
).toBeInTheDocument()
expect(wrapper.getByLabelText('Avatar (Nicole Braun)')).toBeInTheDocument()
expect(wrapper.getByLabelText('Avatar (Thomas Ernst)')).toBeInTheDocument()
expect(wrapper.getAllByTestId('common-link')).toHaveLength(3)
expect(wrapper.getAllByTestId('common-link').at(0)).toHaveAttribute(
'href',
'/user/profile/2',
)
expect(wrapper.getAllByTestId('common-link').at(0)).toHaveAttribute('href', '/user/profile/2')
})
it('renders a list of organizations', async () => {

View File

@@ -12,17 +12,9 @@ defineProps<Props>()
</script>
<template>
<CommonLink
:link="`/organizations/${entity.internalId}`"
class="flex gap-2 hover:no-underline!"
>
<CommonLink :link="`/organizations/${entity.internalId}`" class="flex gap-2 hover:no-underline!">
<!-- :TODO add popover with user details-->
<CommonOrganizationAvatar
:entity="entity as AvatarOrganization"
size="small"
/>
<CommonLabel class="text-blue-800! hover:underline"
>{{ entity.name }}
</CommonLabel>
<CommonOrganizationAvatar :entity="entity as AvatarOrganization" size="small" />
<CommonLabel class="text-blue-800! hover:underline">{{ entity.name }} </CommonLabel>
</CommonLink>
</template>

View File

@@ -25,8 +25,7 @@ defineProps<Props>()
<CommonUserAvatar
class="rounded-full outline-2 outline-transparent group-hover:outline-blue-900 group-focus-visible:outline-blue-900"
:class="{
'outline-2! outline-blue-800!':
slotProps?.isOpen && slotProps.hasOpenViaLongClick,
'outline-2! outline-blue-800!': slotProps?.isOpen && slotProps.hasOpenViaLongClick,
}"
:entity="entity"
/>

View File

@@ -9,7 +9,6 @@ const modules = import.meta.glob<EntityModule>(['./*.ts', '!./index.ts'], {
})
const entityModules = Object.entries(modules).reduce(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(acc, [_, module]) => {
acc[module.type] = module
return acc

View File

@@ -5,8 +5,6 @@ import { EntityType } from '#desktop/components/CommonSimpleEntityList/types.ts'
export default {
type: EntityType.Organization,
component: () =>
import(
'#desktop/components/CommonSimpleEntityList/entity/OrganizationEntity.vue'
),
import('#desktop/components/CommonSimpleEntityList/entity/OrganizationEntity.vue'),
emptyMessage: __('No organizations found'),
}

View File

@@ -4,7 +4,6 @@ import { EntityType } from '#desktop/components/CommonSimpleEntityList/types.ts'
export default {
type: EntityType.User,
component: () =>
import('#desktop/components/CommonSimpleEntityList/entity/UserEntity.vue'),
component: () => import('#desktop/components/CommonSimpleEntityList/entity/UserEntity.vue'),
emptyMessage: __('No members found'),
}

View File

@@ -12,9 +12,7 @@ const props = withDefaults(defineProps<Props>(), {
label: __('Content loader'),
})
const roundedClass = computed(() =>
props.rounded ? 'rounded-full' : 'rounded-md',
)
const roundedClass = computed(() => (props.rounded ? 'rounded-full' : 'rounded-md'))
</script>
<template>

View File

@@ -23,20 +23,19 @@ describe('CommonSkeleton', () => {
describe('a11y', () => {
it('should have no accessibility violations', async () => {
const wrapper = renderComponent(CommonSkeleton, {
label: 'Avatar skeleton',
props: {
label: 'Avatar skeleton',
},
})
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
expect(wrapper.getByRole('progressbar'), {
busy: true,
const progressBar = wrapper.getByRole('progressbar', {
name: 'Avatar skeleton',
value: {
min: 0,
max: 100,
text: 'Please wait until content is loaded',
},
}).toBeInTheDocument()
busy: true,
})
expect(progressBar).toHaveAttribute('aria-valuemin', '0')
expect(progressBar).toHaveAttribute('aria-valuemax', '100')
expect(progressBar).toHaveAttribute('aria-valuetext', 'Please wait until content is loaded')
})
})
})

View File

@@ -52,12 +52,7 @@ const iconClassMap = {
]"
:aria-disabled="disabled"
>
<CommonIcon
v-if="icon"
:name="icon"
:size="iconClassMap[props.size]"
decorative
/>
<CommonIcon v-if="icon" :name="icon" :size="iconClassMap[props.size]" decorative />
<template v-if="label">
{{ $t(label) }}
</template>

View File

@@ -30,9 +30,7 @@ const tabInstances = useTemplateRef('tabs')
const isTabMode = computed(() => !props.multiple)
const labelSize = computed(() => (props.size === 'large' ? 'medium' : 'small'))
const defaultTabIndex = computed(() =>
props.tabs.findIndex((tab) => tab.default),
)
const defaultTabIndex = computed(() => props.tabs.findIndex((tab) => tab.default))
const selectedIndex = ref<number | null>(null)
@@ -43,9 +41,7 @@ const activeTabs = computed(() =>
const isActiveTab = (tab: Tab) => activeTabs.value.includes(tab.key)
const calcMarkerSize = () => {
const tabElement = tabInstances.value?.at(
selectedIndex.value ?? defaultTabIndex.value,
)
const tabElement = tabInstances.value?.at(selectedIndex.value ?? defaultTabIndex.value)
if (!tabElement || !markerElement.value) return
Object.assign(markerElement.value.style, {
@@ -60,9 +56,7 @@ watch(
() => props.modelValue,
(activeTabKey) => {
if (props.multiple) return
selectedIndex.value = props.tabs.findIndex(
(tab) => activeTabKey === tab.key,
)
selectedIndex.value = props.tabs.findIndex((tab) => activeTabKey === tab.key)
calcMarkerSize()
},
)

View File

@@ -35,9 +35,7 @@ describe('CommonTabGroup', () => {
})
waitFor(() => {
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent(
'Tab 1',
)
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent('Tab 1')
})
})
@@ -56,9 +54,7 @@ describe('CommonTabGroup', () => {
})
waitFor(() => {
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent(
'Tab 4',
)
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent('Tab 4')
})
})
@@ -72,9 +68,7 @@ describe('CommonTabGroup', () => {
await wrapper.events.click(wrapper.getByRole('tab', { name: 'Tab 2' }))
waitFor(() => {
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent(
'Tab 2',
)
expect(wrapper.getByRole('tab', { selected: true })).toHaveTextContent('Tab 2')
})
})
})
@@ -116,17 +110,13 @@ describe('CommonTabGroup', () => {
await wrapper.events.click(wrapper.getByText('Agent'))
waitFor(() => {
expect(wrapper.getAllByRole('option', { selected: true })).toHaveLength(
2,
)
expect(wrapper.getAllByRole('option', { selected: true })).toHaveLength(2)
})
await wrapper.events.click(wrapper.getByText('Admin'))
waitFor(() => {
expect(wrapper.getAllByRole('option', { selected: true })).toHaveLength(
1,
)
expect(wrapper.getAllByRole('option', { selected: true })).toHaveLength(1)
})
})
})

View File

@@ -14,9 +14,7 @@ const props = defineProps<Props>()
const hasRowId = computed(() => props.itemIds?.has(props.item.id))
const checkboxIcon = computed(() =>
hasRowId.value ? 'check-square' : 'square',
)
const checkboxIcon = computed(() => (hasRowId.value ? 'check-square' : 'square'))
const disabled = computed(() =>
props.item.policy ? !props.item.policy.update : !!props.item.disabled,
@@ -32,22 +30,15 @@ const disabled = computed(() =>
hasRowId,
'text-gray-100! dark:text-neutral-400!': hasRowId,
'group-hover/checkbox:text-blue-800!': !disabled,
'opacity-30 group-hover:text-gray-100! group-hover:dark:text-neutral-400!':
disabled,
'opacity-30 group-hover:text-gray-100! group-hover:dark:text-neutral-400!': disabled,
}"
:aria-label="
disabled
? undefined
: hasRowId
? $t('Deselect this entry')
: $t('Select this entry')
disabled ? undefined : hasRowId ? $t('Deselect this entry') : $t('Select this entry')
"
:tabindex="disabled ? -1 : 0"
:aria-disabled="!!disabled"
:aria-description="
disabled && props.item.policy
? $t('You do not have permission to update')
: undefined
disabled && props.item.policy ? $t('You do not have permission to update') : undefined
"
:aria-checked="!!hasRowId"
>

View File

@@ -1,33 +1,16 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import {
useEventListener,
useInfiniteScroll,
useLocalStorage,
whenever,
} from '@vueuse/core'
import { useEventListener, useInfiniteScroll, useLocalStorage, whenever } from '@vueuse/core'
import { delay, isEqual, merge } from 'lodash-es'
import {
computed,
nextTick,
onMounted,
ref,
type Ref,
toRef,
useTemplateRef,
watch,
} from 'vue'
import { computed, nextTick, onMounted, ref, type Ref, toRef, useTemplateRef, watch } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'
import ObjectAttributeContent from '#shared/components/ObjectAttributes/ObjectAttribute.vue'
import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import type { TicketById } from '#shared/entities/ticket/types.ts'
import {
EnumObjectManagerObjects,
EnumOrderDirection,
} from '#shared/graphql/types.ts'
import { EnumObjectManagerObjects, EnumOrderDirection } from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import emitter from '#shared/utils/emitter.ts'
@@ -72,33 +55,24 @@ const { attributesLookup: objectAttributesLookup } = props.object
const localAttributesLookup = computed(() => {
const lookup: Map<string, TableAttribute> = new Map()
props.attributes?.forEach((attribute) =>
lookup.set(attribute.name, attribute),
)
props.attributes?.forEach((attribute) => lookup.set(attribute.name, attribute))
return lookup
})
const findAttribute = <T,>(headerName: string, lookup: Map<string, T>) =>
lookup?.get(headerName) ||
lookup?.get(`${headerName}_id`) ||
lookup?.get(`${headerName}_ids`)
lookup?.get(headerName) || lookup?.get(`${headerName}_id`) || lookup?.get(`${headerName}_ids`)
const localHeaders = computed(() =>
props.groupBy
? props.headers.filter((header) => header !== props.groupBy)
: props.headers,
props.groupBy ? props.headers.filter((header) => header !== props.groupBy) : props.headers,
)
const rightAlignedDataTypes = ['date', 'datetime', 'integer']
const rightAlignedDataTypes = new Set(['date', 'datetime', 'integer'])
const addLocalHeaders = (table: TableAttribute[]) =>
localHeaders.value.forEach((headerName) => {
// Try to find matching attribute from both sources
const localAttribute = findAttribute(
headerName,
localAttributesLookup.value,
)
const localAttribute = findAttribute(headerName, localAttributesLookup.value)
const objectAttribute = objectAttributesLookup?.value
? findAttribute(headerName, objectAttributesLookup.value)
: null
@@ -125,7 +99,7 @@ const addLocalHeaders = (table: TableAttribute[]) =>
// Set default alignment for right-aligned data types.
if (
rightAlignedDataTypes.includes(mergedAttribute.dataType) &&
rightAlignedDataTypes.has(mergedAttribute.dataType) &&
!mergedAttribute.columnPreferences?.alignContent
) {
mergedAttribute.columnPreferences ||= {}
@@ -144,10 +118,7 @@ const tableAttributes = computed(() => {
})
const tableColumnLength = computed(
() =>
tableAttributes.value.length +
(props.actions ? 1 : 0) +
(props.hasCheckboxColumn ? 1 : 0),
() => tableAttributes.value.length + (props.actions ? 1 : 0) + (props.hasCheckboxColumn ? 1 : 0),
)
const tableElement = useTemplateRef('table')
@@ -160,17 +131,13 @@ const setHeaderWidths = (reset?: boolean) => {
const availableWidth = tableElement.value.parentElement.clientWidth
const tableWidth =
availableWidth < MINIMUM_TABLE_WIDTH ? MINIMUM_TABLE_WIDTH : availableWidth
const tableWidth = availableWidth < MINIMUM_TABLE_WIDTH ? MINIMUM_TABLE_WIDTH : availableWidth
tableElement.value.style.width = `${tableWidth}px`
let shouldReset = reset
if (
tableAttributes.value.length !==
Object.keys(headerWidthsRelativeStorage.value).length
)
if (tableAttributes.value.length !== Object.keys(headerWidthsRelativeStorage.value).length)
shouldReset = true
tableAttributes.value.forEach((tableAttribute) => {
@@ -184,8 +151,7 @@ const setHeaderWidths = (reset?: boolean) => {
return
}
const headerWidthRelative =
headerWidthsRelativeStorage.value[tableAttribute.name]
const headerWidthRelative = headerWidthsRelativeStorage.value[tableAttribute.name]
const headerWidth =
tableAttribute.headerPreferences?.displayWidth ??
@@ -199,8 +165,7 @@ const storeHeaderWidths = (headerWidths: Record<string, number>) => {
headerWidthsRelativeStorage.value = Object.keys(headerWidths).reduce(
(headerWidthsRelative, headerName) => {
if (!tableElement.value) return headerWidthsRelative
headerWidthsRelative[headerName] =
headerWidths[headerName] / tableElement.value.clientWidth
headerWidthsRelative[headerName] = headerWidths[headerName] / tableElement.value.clientWidth
return headerWidthsRelative
},
{} as Record<string, number>,
@@ -211,9 +176,7 @@ const calculateHeaderWidths = () => {
const headerWidths: Record<string, number> = {}
tableAttributes.value.forEach((tableAttribute) => {
const headerWidth = document.getElementById(
`${tableAttribute.name}-header`,
)?.clientWidth
const headerWidth = document.getElementById(`${tableAttribute.name}-header`)?.clientWidth
if (!headerWidth) return
@@ -227,10 +190,7 @@ const initializeHeaderWidths = (storageKeyId?: string) => {
if (storageKeyId) {
// FIXME: This is needed because storage key as a reactive value is unsupported.
// eslint-disable-next-line vue/no-ref-as-operand
headerWidthsRelativeStorage = useLocalStorage<Record<string, number>>(
storageKeyId,
{},
)
headerWidthsRelativeStorage = useLocalStorage<Record<string, number>>(storageKeyId, {})
}
nextTick(() => {
@@ -246,7 +206,9 @@ const resetHeaderWidths = () => {
watch(() => props.storageKeyId, initializeHeaderWidths)
watch(localHeaders, () => initializeHeaderWidths())
watch(localHeaders, () => {
initializeHeaderWidths()
})
onMounted(() => {
if (!props.storageKeyId) return
@@ -256,23 +218,15 @@ onMounted(() => {
useEventListener('resize', () => initializeHeaderWidths())
emitter.on('main-sidebar-transition', () => initializeHeaderWidths())
const getTooltipText = (
item: TableAdvancedItem,
tableAttribute: TableAttribute,
) =>
tableAttribute.headerPreferences?.truncate
? item[tableAttribute.name]
: undefined
const getTooltipText = (item: TableAdvancedItem, tableAttribute: TableAttribute) =>
tableAttribute.headerPreferences?.truncate ? item[tableAttribute.name] : undefined
const modelCheckedItemIds = defineModel<Set<ID>>('checkedItemIds', {
required: false,
default: () => new Set(),
})
const updateCheckedItem = (
item: TableAdvancedItem,
event: MouseEvent | KeyboardEvent,
) => {
const updateCheckedItem = (item: TableAdvancedItem, event: MouseEvent | KeyboardEvent) => {
if (item.policy ? !item.policy.update : item.disabled) return
event.stopPropagation()
@@ -302,15 +256,13 @@ const localItems = computed<TableAdvancedItem[]>((currentItems) => {
})
const remainingItems = computed(() => {
const itemCount =
props.totalItems >= props.maxItems ? props.maxItems : props.totalItems
const itemCount = props.totalItems >= props.maxItems ? props.maxItems : props.totalItems
return itemCount - localItems.value.length
})
const sort = (column: string) => {
const newDirection =
props.orderBy === column &&
props.orderDirection === EnumOrderDirection.Ascending
props.orderBy === column && props.orderDirection === EnumOrderDirection.Ascending
? EnumOrderDirection.Descending
: EnumOrderDirection.Ascending
@@ -320,9 +272,7 @@ const sort = (column: string) => {
const isSorted = (column: string) => props.orderBy === column
const sortIcon = computed(() =>
props.orderDirection === EnumOrderDirection.Ascending
? 'arrow-up-short'
: 'arrow-down-short',
props.orderDirection === EnumOrderDirection.Ascending ? 'arrow-up-short' : 'arrow-down-short',
)
let currentGroupByValueIndex = -1
@@ -331,10 +281,7 @@ const groupByAttribute = computed(() => {
if (!props.groupBy) return null
// Try to find matching attribute from both sources
const localAttribute = findAttribute(
props.groupBy,
localAttributesLookup.value,
)
const localAttribute = findAttribute(props.groupBy, localAttributesLookup.value)
const objectAttribute = objectAttributesLookup?.value
? findAttribute(props.groupBy, objectAttributesLookup.value)
: null
@@ -345,9 +292,7 @@ const groupByAttribute = computed(() => {
const groupByAttributeItemName = computed(() => {
if (!groupByAttribute.value) return
return (
groupByAttribute.value.dataOption?.belongs_to || groupByAttribute.value.name
)
return groupByAttribute.value.dataOption?.belongs_to || groupByAttribute.value.name
})
const groupByRowCounts = computed(() => {
@@ -360,14 +305,11 @@ const groupByRowCounts = computed(() => {
let lastValue: string | number
return localItems.value.reduce((groupByRowIds: string[][], item) => {
const value = (
isRelation && item[name] ? (item[name] as ObjectLike).id : item[name]
) as string | number
const value = (isRelation && item[name] ? (item[name] as ObjectLike).id : item[name]) as
| string
| number
if (
(lastValue && value !== lastValue) ||
(groupByRowIds.length > 0 && !lastValue && value)
) {
if ((lastValue && value !== lastValue) || (groupByRowIds.length > 0 && !lastValue && value)) {
groupByValueIndex += 1
}
@@ -473,11 +415,7 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
:class="{ 'border-shadow-b': !reachedScrollTop }"
>
<tr>
<th
v-if="hasCheckboxColumn"
id="checkbox-header"
class="relative h-10 w-8 p-2.5 text-xs"
>
<th v-if="hasCheckboxColumn" id="checkbox-header" class="relative h-10 w-8 p-2.5 text-xs">
<!-- <div
role="checkbox"
:class="{
@@ -510,54 +448,35 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
class="relative h-10 p-2.5 text-xs"
:class="[
tableAttribute.headerPreferences?.headerClass,
cellAlignmentClasses[
tableAttribute.columnPreferences?.alignContent ?? 'left'
],
cellAlignmentClasses[tableAttribute.columnPreferences?.alignContent ?? 'left'],
]"
>
<slot
:name="`column-header-${tableAttribute.name}`"
:attribute="tableAttribute"
>
<slot :name="`column-header-${tableAttribute.name}`" :attribute="tableAttribute">
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions,vuejs-accessibility/mouse-events-have-key-events-->
<div
class="flex items-center gap-1"
:class="[
cellAlignmentClasses[
tableAttribute.columnPreferences?.alignContent || 'left'
],
cellAlignmentClasses[tableAttribute.columnPreferences?.alignContent || 'left'],
{
'hover:cursor-pointer focus-visible:rounded-xs focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-blue-800':
!tableAttribute.headerPreferences?.noSorting,
},
]"
:role="
tableAttribute.headerPreferences?.noSorting
? undefined
: 'button'
"
:tabindex="
tableAttribute.headerPreferences?.noSorting ? undefined : '0'
"
:role="tableAttribute.headerPreferences?.noSorting ? undefined : 'button'"
:tabindex="tableAttribute.headerPreferences?.noSorting ? undefined : '0'"
:aria-label="
orderDirection === EnumOrderDirection.Ascending
? $t('Sorted ascending')
: $t('Sorted descending')
"
@click="
tableAttribute.headerPreferences?.noSorting
? undefined
: sort(tableAttribute.name)
tableAttribute.headerPreferences?.noSorting ? undefined : sort(tableAttribute.name)
"
@keydown.enter.prevent="
tableAttribute.headerPreferences?.noSorting
? undefined
: sort(tableAttribute.name)
tableAttribute.headerPreferences?.noSorting ? undefined : sort(tableAttribute.name)
"
@keydown.space.prevent="
tableAttribute.headerPreferences?.noSorting
? undefined
: sort(tableAttribute.name)
tableAttribute.headerPreferences?.noSorting ? undefined : sort(tableAttribute.name)
"
>
<CommonLabel
@@ -566,27 +485,17 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
tableAttribute.headerPreferences?.labelClass,
{
'sr-only': tableAttribute.headerPreferences?.hideLabel,
'text-black! dark:text-white!': isSorted(
tableAttribute.name,
),
'text-black! dark:text-white!': isSorted(tableAttribute.name),
'hover:text-black! dark:hover:text-white!':
!tableAttribute.headerPreferences?.noSorting,
},
]"
size="small"
>
{{
$t(
tableAttribute.label,
...(tableAttribute.labelPlaceholder || []),
)
}}
{{ $t(tableAttribute.label, ...(tableAttribute.labelPlaceholder || [])) }}
</CommonLabel>
<CommonIcon
v-if="
!tableAttribute.headerPreferences?.noSorting &&
isSorted(tableAttribute.name)
"
v-if="!tableAttribute.headerPreferences?.noSorting && isSorted(tableAttribute.name)"
class="shrink-0 text-blue-800"
:name="sortIcon"
size="xs"
@@ -595,41 +504,33 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
</div>
</slot>
<slot
:name="`header-suffix-${tableAttribute.name}`"
:item="tableAttribute"
/>
<slot :name="`header-suffix-${tableAttribute.name}`" :item="tableAttribute" />
<HeaderResizeLine
v-if="
!tableAttribute.headerPreferences?.noResize &&
index !== tableAttributes.length - 1
!tableAttribute.headerPreferences?.noResize && index !== tableAttributes.length - 1
"
@resize="calculateHeaderWidths"
@reset="resetHeaderWidths"
/>
</th>
<th v-if="actions" class="h-10 w-0 p-2.5 text-center">
<CommonLabel
class="font-normal text-stone-200! dark:text-neutral-500!"
size="small"
<CommonLabel class="font-normal text-stone-200! dark:text-neutral-500!" size="small"
>{{ $t('Actions') }}
</CommonLabel>
</th>
</tr>
</thead>
<!-- :TODO tabindex should be -1 re-evaluate when we work on bulk action with checkbox -->
<!-- SR should not be able to focus the row but each action node -->
<tbody
class="relative"
:inert="isSorting"
:class="{
'opacity-50 before:absolute before:z-20 before:h-full before:w-full':
isSorting,
'opacity-50 before:absolute before:z-20 before:h-full before:w-full': isSorting,
}"
>
<template
v-for="(item, index) in localItems"
:key="`${index}-${item.id}`"
>
<template v-for="(item, index) in localItems" :key="`${index}-${item.id}`">
<TableRowGroupBy
v-if="groupByAttribute && showGroupByRow(item)"
:item="item"
@@ -641,15 +542,12 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
/>
<TableRow
:item="item"
:is-row-selected="
!hasCheckboxColumn && item.id === props.selectedRowId
"
:is-row-selected="!hasCheckboxColumn && item.id === props.selectedRowId"
tabindex="-1"
:has-checkbox="hasCheckboxColumn"
:no-auto-striping="!!groupByAttribute"
:is-striped="
!!groupByAttribute &&
groupIndexByRowId(currentGroupByValueIndex, item.id) % 2 === 0
!!groupByAttribute && groupIndexByRowId(currentGroupByValueIndex, item.id) % 2 === 0
"
v-on="rowHandlers"
>
@@ -668,9 +566,7 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
:key="`${item.id}-${tableAttribute.name}`"
class="h-10 p-2.5 text-sm"
:class="[
cellAlignmentClasses[
tableAttribute?.columnPreferences?.alignContent || 'left'
],
cellAlignmentClasses[tableAttribute?.columnPreferences?.alignContent || 'left'],
{
'max-w-32 truncate text-black dark:text-white':
tableAttribute?.headerPreferences?.truncate,
@@ -687,15 +583,8 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
<CommonLink
v-if="tableAttribute.columnPreferences?.link"
v-tooltip.truncate="getTooltipText(item, tableAttribute)"
:link="
tableAttribute.columnPreferences.link.getLink(
item,
tableAttribute,
)
"
:open-in-new-tab="
tableAttribute.columnPreferences.link.openInNewTab
"
:link="tableAttribute.columnPreferences.link.getLink(item, tableAttribute)"
:open-in-new-tab="tableAttribute.columnPreferences.link.openInNewTab"
:internal="tableAttribute.columnPreferences.link.internal"
:class="[
{

View File

@@ -12,12 +12,7 @@ import TableRow from '#desktop/components/CommonTable/TableRow.vue'
import { useCellContent } from './composables/useCellContent.ts'
import type {
SimpleTableProps,
TableSimpleHeader,
TableItem,
TableItemLinkValue,
} from './types.ts'
import type { SimpleTableProps, TableSimpleHeader, TableItem, TableItemLinkValue } from './types.ts'
const props = withDefaults(defineProps<SimpleTableProps>(), {
showCaption: false,
@@ -36,8 +31,7 @@ const cellAlignmentClasses = {
const tableHeaders = computed(() => props.headers)
const columnSeparatorClasses =
'border-r border-neutral-100 dark:border-gray-900'
const columnSeparatorClasses = 'border-r border-neutral-100 dark:border-gray-900'
const getTooltipText = (item: TableItem, header: TableSimpleHeader) => {
return header.truncate ? item[header.key] : undefined
@@ -57,16 +51,11 @@ const { getCellContentComponent } = useCellContent()
const checkedRows = defineModel<Array<TableItem>>('checkedRows', {
required: false,
default: (props: SimpleTableProps) =>
props.items.filter((item) => item.checked), // is not reactive by default and making it reactive causes other issues.
default: (props: SimpleTableProps) => props.items.filter((item) => item.checked), // is not reactive by default and making it reactive causes other issues.
})
const {
hasCheckboxId,
allCheckboxRowsSelected,
selectAllRowCheckboxes,
handleCheckboxUpdate,
} = useTableCheckboxes(checkedRows, toRef(props, 'items'))
const { hasCheckboxId, allCheckboxRowsSelected, selectAllRowCheckboxes, handleCheckboxUpdate } =
useTableCheckboxes(checkedRows, toRef(props, 'items'))
</script>
<template>
@@ -78,18 +67,13 @@ const {
v-for="header in tableHeaders"
:key="header.key"
class="h-10 p-2.5 text-xs ltr:text-left rtl:text-right"
:class="[
header.headerClass,
header.columnSeparator && columnSeparatorClasses,
]"
:class="[header.headerClass, header.columnSeparator && columnSeparatorClasses]"
>
<FormKit
v-if="hasCheckboxColumn && header.key === 'checkbox'"
name="checkbox-all-rows"
:aria-label="
allCheckboxRowsSelected
? $t('Deselect all entries')
: $t('Select all entries')
allCheckboxRowsSelected ? $t('Deselect all entries') : $t('Select all entries')
"
type="checkbox"
:model-value="allCheckboxRowsSelected"
@@ -112,9 +96,7 @@ const {
<slot :name="`header-suffix-${header.key}`" :item="header" />
</th>
<th v-if="actions" class="h-10 w-0 p-2.5 text-center">
<CommonLabel
class="font-normal text-stone-200! dark:text-neutral-500!"
size="small"
<CommonLabel class="font-normal text-stone-200! dark:text-neutral-500!" size="small"
>{{ $t('Actions') }}
</CommonLabel>
</th>
@@ -147,9 +129,7 @@ const {
:key="`checkbox-${item.id}-${header.key}`"
:name="`checkbox-${item.id}`"
:aria-label="
hasCheckboxId(item.id)
? $t('Deselect this entry')
: $t('Select this entry')
hasCheckboxId(item.id) ? $t('Deselect this entry') : $t('Select this entry')
"
type="checkbox"
alternative-backrgound
@@ -177,8 +157,7 @@ const {
v-tooltip.truncate="getTooltipText(item, header)"
v-bind="item[header.key] as CommonLinkProps"
:class="{
'ltr:text-black rtl:text-black dark:text-white':
isRowSelected,
'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
}"
class="truncate text-sm group-hover:text-black group-focus-visible:text-white group-active:text-white hover:no-underline! group-hover:dark:text-white"
@click.stop

View File

@@ -23,8 +23,7 @@ const resizing = ref(false)
const currentHeader = computed(() => resizeLine.value?.parentElement)
const nextHeader = computed(
() =>
currentHeader.value?.nextElementSibling as HTMLElement | null | undefined,
() => currentHeader.value?.nextElementSibling as HTMLElement | null | undefined,
)
const currentHeaderWidth = ref(0)
@@ -106,9 +105,7 @@ const handleTouchstart = (event: TouchEvent) => {
resizing.value = true
if (event.targetTouches[0]) resizeStartX.value = event.targetTouches[0].pageX
else
resizeStartX.value =
event.changedTouches[event.changedTouches.length - 1].pageX
else resizeStartX.value = event.changedTouches[event.changedTouches.length - 1].pageX
addRemoveResizingListener('touchend')
setCurrentHeaderWidths()

View File

@@ -45,9 +45,7 @@ const rowEventHandler = computed(() =>
: { attrs: {}, events: {} },
)
const hasScreenReaderHelpText = computed(
() => !!document?.getElementById(rowId),
)
const hasScreenReaderHelpText = computed(() => !!document?.getElementById(rowId))
</script>
<template>
@@ -66,11 +64,7 @@ const hasScreenReaderHelpText = computed(
<template v-if="!hasScreenReaderHelpText">
<Teleport to="body">
<p
v-if="rowEventHandler.attrs['aria-describedby']"
:id="rowId"
class="sr-only absolute"
>
<p v-if="rowEventHandler.attrs['aria-describedby']" :id="rowId" class="sr-only absolute">
{{ __('Select table row') }}
</p>
</Teleport>

View File

@@ -37,20 +37,13 @@ const completedGroup = computed(() => {
<td :colspan="tableColumnLength">
<CommonDivider class="mt-2 mb-1 group-first:mt-0" />
<div class="h-10 p-2.5">
<CommonLabel
class="cursor-default truncate text-stone-200! dark:text-neutral-500!"
>
<CommonLabel class="cursor-default truncate text-stone-200! dark:text-neutral-500!">
<ObjectAttributeContent
mode="table"
:attribute="attribute as unknown as ObjectAttribute"
:object="item"
/>
<CommonBadge
class="ms-0.5 leading-snug font-bold"
rounded
size="xs"
variant="info"
>
<CommonBadge class="ms-0.5 leading-snug font-bold" rounded size="xs" variant="info">
{{ groupByRowCount }}{{ completedGroup ? '' : '+' }}
</CommonBadge>
</CommonLabel>

View File

@@ -56,10 +56,7 @@ describe('CellCheckbox.vue', () => {
})
const checkbox = wrapper.getByRole('checkbox')
expect(checkbox).toHaveAttribute(
'aria-description',
'You do not have permission to update',
)
expect(checkbox).toHaveAttribute('aria-description', 'You do not have permission to update')
})
it('displays reason to show missing update message to user if disabled generically', () => {

View File

@@ -6,20 +6,14 @@ import { vi } from 'vitest'
import { ref } from 'vue'
import ticketObjectAttributes from '#tests/graphql/factories/fixtures/ticket-object-attributes.ts'
import {
type ExtendedMountingOptions,
renderComponent,
} from '#tests/support/components/index.ts'
import { type ExtendedMountingOptions, renderComponent } from '#tests/support/components/index.ts'
import { mockRouterHooks } from '#tests/support/mock-vue-router.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import { mockObjectManagerFrontendAttributesQuery } from '#shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.mocks.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
import {
convertToGraphQLId,
getIdFromGraphQLId,
} from '#shared/graphql/utils.ts'
import { convertToGraphQLId, getIdFromGraphQLId } from '#shared/graphql/utils.ts'
import { i18n } from '#shared/i18n.ts'
import type { ObjectWithId } from '#shared/types/utils.ts'
@@ -76,10 +70,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
const modules = await importOriginal<typeof import('@vueuse/core')>()
return {
...modules,
useInfiniteScroll: (
scrollContainer: HTMLElement,
callback: () => Promise<void>,
) => {
useInfiniteScroll: (scrollContainer: HTMLElement, callback: () => Promise<void>) => {
callback()
return { reset: vi.fn(), isLoading: ref(false) }
},
@@ -362,9 +353,7 @@ describe('CommonAdvancedTable', () => {
caption: 'Table caption',
})
expect(wrapper.getByText('Awesome Cell Header')).toHaveClass(
'text-red-500 font-bold',
)
expect(wrapper.getByText('Awesome Cell Header')).toHaveClass('text-red-500 font-bold')
})
it('supports adding a link to a cell', async () => {
@@ -376,8 +365,7 @@ describe('CommonAdvancedTable', () => {
columnPreferences: {
link: {
internal: true,
getLink: (item: ObjectWithId) =>
`/tickets/${getIdFromGraphQLId(item.id)}`,
getLink: (item: ObjectWithId) => `/tickets/${getIdFromGraphQLId(item.id)}`,
},
},
},
@@ -436,18 +424,14 @@ describe('CommonAdvancedTable', () => {
await wrapper.events.click(rowCheckboxes[1])
await waitFor(() =>
expect(Array.from(checkedItemIds.value.keys())).toContain(items[0].id),
)
await waitFor(() => expect(Array.from(checkedItemIds.value.keys())).toContain(items[0].id))
await waitFor(() => expect(rowCheckboxes[0]).not.toHaveAttribute('checked'))
expect(rowCheckboxes[1]).not.toHaveAttribute('checked')
await wrapper.events.click(rowCheckboxes[1])
expect(
await wrapper.findByLabelText('Deselect this entry'),
).toBeInTheDocument()
expect(await wrapper.findByLabelText('Deselect this entry')).toBeInTheDocument()
})
it('renders checklist item as disabled if update policy is not given', async () => {
@@ -609,9 +593,7 @@ describe('CommonAdvancedTable', () => {
})
expect(
wrapper.getByText(
'You reached the table limit of 20 tickets (10 remaining).',
),
wrapper.getByText('You reached the table limit of 20 tickets (10 remaining).'),
).toBeInTheDocument()
scrollContainer.remove()
@@ -639,9 +621,7 @@ describe('CommonAdvancedTable', () => {
orderBy: 'label',
})
expect(
wrapper.getByText("You don't have more tickets to load."),
).toBeInTheDocument()
expect(wrapper.getByText("You don't have more tickets to load.")).toBeInTheDocument()
scrollContainer.remove()
})
@@ -676,8 +656,6 @@ describe('CommonAdvancedTable', () => {
caption: 'Table caption',
})
expect(
wrapper.getByRole('row', { name: 'Nicole Braun 2+' }),
).toBeInTheDocument()
expect(wrapper.getByRole('row', { name: 'Nicole Braun 2+' })).toBeInTheDocument()
})
})

View File

@@ -172,6 +172,8 @@
</th>
</tr>
</thead>
<!-- :TODO tabindex should be -1 re-evaluate when we work on bulk action with checkbox -->
<!-- SR should not be able to focus the row but each action node -->
<tbody
class="relative"
data-v-fd04734e=""

View File

@@ -3,10 +3,7 @@
import { waitFor } from '@testing-library/vue'
import { vi } from 'vitest'
import {
type ExtendedMountingOptions,
renderComponent,
} from '#tests/support/components/index.ts'
import { type ExtendedMountingOptions, renderComponent } from '#tests/support/components/index.ts'
import { i18n } from '#shared/i18n.ts'
@@ -185,9 +182,7 @@ describe('CommonSimpleTable', () => {
await waitFor(() => {
expect(wrapper.getByText('Some text to be truncated')).toBeInTheDocument()
expect(
wrapper.getByLabelText('Some text to be truncated'),
).toBeInTheDocument()
expect(wrapper.getByLabelText('Some text to be truncated')).toBeInTheDocument()
})
})
@@ -252,9 +247,7 @@ describe('CommonSimpleTable', () => {
],
})
expect(wrapper.getByText('Awesome Cell Header')).toHaveClass(
'text-red-500 font-bold',
)
expect(wrapper.getByText('Awesome Cell Header')).toHaveClass('text-red-500 font-bold')
})
it('supports adding a link to a cell', () => {

View File

@@ -17,9 +17,7 @@ export const useListTable = <T>(
) => {
const { userId } = storeToRefs(useSessionStore())
const storageKeyId = computed(
() => `${userId.value}-table-headers-${props.tableId}`,
)
const storageKeyId = computed(() => `${userId.value}-table-headers-${props.tableId}`)
const loadMore = async () => {
await props.onLoadMore?.()

View File

@@ -11,8 +11,7 @@ export const useSkeletonLoadingCount = (
const visibleSkeletonLoadingCount = computed(() => {
const maxVisibleRowCount = Math.ceil(screenHeight.value / 40)
if (count.value && count.value > maxVisibleRowCount)
return maxVisibleRowCount
if (count.value && count.value > maxVisibleRowCount) return maxVisibleRowCount
return count.value
})

View File

@@ -8,9 +8,7 @@ export const useTableCheckboxes = (
checkedRowItems: ModelRef<TableItem[]>,
items: Ref<TableItem[]>,
) => {
const allCheckboxRowsSelected = computed(
() => checkedRowItems.value.length >= items.value.length,
)
const allCheckboxRowsSelected = computed(() => checkedRowItems.value.length >= items.value.length)
const selectAllRowCheckboxes = (value?: boolean) => {
if (allCheckboxRowsSelected.value === value) return
@@ -18,16 +16,12 @@ export const useTableCheckboxes = (
if (value) {
checkedRowItems.value = items.value
} else {
checkedRowItems.value = items.value.filter(
(item) => item.disabled && item.checked,
)
checkedRowItems.value = items.value.filter((item) => item.disabled && item.checked)
}
}
const handleCheckboxUpdate = (item: TableItem) => {
const isChecked = checkedRowItems.value.some(
(selectedItem) => selectedItem.id === item.id,
)
const isChecked = checkedRowItems.value.some((selectedItem) => selectedItem.id === item.id)
if (!isChecked) {
// Overwrite entire array to trigger reactivity since defineModel default value is not reactive.
@@ -40,9 +34,7 @@ export const useTableCheckboxes = (
}
const hasCheckboxId = (itemId: string | number) =>
checkedRowItems.value.some(
(selectedItem) => selectedItem.id.toString() === itemId.toString(),
)
checkedRowItems.value.some((selectedItem) => selectedItem.id.toString() === itemId.toString())
return {
allCheckboxRowsSelected,

View File

@@ -2,10 +2,7 @@
import type { Props as CommonLinkProps } from '#shared/components/CommonLink/CommonLink.vue'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import type {
EnumObjectManagerObjects,
EnumOrderDirection,
} from '#shared/graphql/types.ts'
import type { EnumObjectManagerObjects, EnumOrderDirection } from '#shared/graphql/types.ts'
import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
@@ -89,10 +86,7 @@ export interface TableAttribute {
headerPreferences?: TableHeaderPreference
columnPreferences?: TableColumnPreference & {
link?: Pick<CommonLinkProps, 'internal' | 'openInNewTab'> & {
getLink: (
item: TableAdvancedItem,
tableAttribute: TableAttribute,
) => string
getLink: (item: TableAdvancedItem, tableAttribute: TableAttribute) => string
}
}
dataType: ObjectAttribute['dataType']

View File

@@ -3,10 +3,7 @@
<script setup lang="ts">
import { toRef } from 'vue'
import {
useEscalationState,
EscalationState,
} from '#shared/composables/useEscalationState.ts'
import { useEscalationState, EscalationState } from '#shared/composables/useEscalationState.ts'
import type { Scalars } from '#shared/graphql/types.ts'
export interface Props {
@@ -21,9 +18,7 @@ const escalationState = useEscalationState(toRef(() => props.escalationAt))
<template>
<CommonBadge
v-if="escalationAt && escalationState !== EscalationState.None"
:variant="
escalationState === EscalationState.Escalated ? 'danger' : 'warning'
"
:variant="escalationState === EscalationState.Escalated ? 'danger' : 'warning'"
class="uppercase"
role="alert"
>

View File

@@ -33,9 +33,7 @@ const ticketColorCode = computed(() => {
<template>
<div v-if="unauthorized" class="flex grow items-center gap-2">
<CommonIcon class="shrink-0 text-red-500" size="tiny" name="x-lg" />
<CommonLabel class="text-black dark:text-white">{{
$t('Access denied')
}}</CommonLabel>
<CommonLabel class="text-black dark:text-white">{{ $t('Access denied') }}</CommonLabel>
</div>
<CommonLink
v-else
@@ -52,11 +50,7 @@ const ticketColorCode = computed(() => {
:aria-labelledby="ticketId"
icon-size="tiny"
/>
<CommonLabel
:id="ticketId"
class="mt-0.5 text-blue-800"
:class="classes?.label"
>
<CommonLabel :id="ticketId" class="mt-0.5 text-blue-800" :class="classes?.label">
{{ ticket?.title }}
</CommonLabel>
</CommonLink>

View File

@@ -31,12 +31,7 @@ const badgeVariant = computed(() => {
</script>
<template>
<CommonBadge
:variant="badgeVariant"
class="uppercase"
role="status"
aria-live="polite"
>
<CommonBadge :variant="badgeVariant" class="uppercase" role="status" aria-live="polite">
<CommonTicketPriorityIndicatorIcon
v-if="config.ui_ticket_priority_icons"
:ui-color="priority?.uiColor"

View File

@@ -36,11 +36,5 @@ const textColor = computed(() => {
</script>
<template>
<CommonIcon
size="xs"
:name="icon"
class="ltr:mr-1.5 rtl:ml-1.5"
:class="textColor"
decorative
/>
<CommonIcon size="xs" :name="icon" class="ltr:mr-1.5 rtl:ml-1.5" :class="textColor" decorative />
</template>

View File

@@ -32,9 +32,7 @@ describe('CommonTicketStateIndicator.vue', () => {
label: 'pending reminder',
})
expect(
view.getByIconName('check-circle-outline-dashed'),
).toBeInTheDocument()
expect(view.getByIconName('check-circle-outline-dashed')).toBeInTheDocument()
})
it('renders escalated state correctly', () => {

View File

@@ -10,10 +10,5 @@ defineProps<{
</script>
<template>
<CommonUserAvatar
v-if="option.user"
:entity="option.user"
size="xs"
no-indicator
/>
<CommonUserAvatar v-if="option.user" :entity="option.user" size="xs" no-indicator />
</template>

View File

@@ -159,9 +159,7 @@ describe('Form - Field - Agent - Query', () => {
expect(filterElement).toBeInTheDocument()
expect(
await wrapper.findByText('Start typing to search…'),
).toBeInTheDocument()
expect(await wrapper.findByText('Start typing to search…')).toBeInTheDocument()
mockAutocompleteSearchAgentQuery({
autocompleteSearchAgent: [testOptions[0]],
@@ -171,9 +169,7 @@ describe('Form - Field - Agent - Query', () => {
await waitForAutocompleteSearchAgentQueryCalls()
expect(
wrapper.queryByText('Start typing to search…'),
).not.toBeInTheDocument()
expect(wrapper.queryByText('Start typing to search…')).not.toBeInTheDocument()
let selectOptions = wrapper.getAllByRole('option')
@@ -191,9 +187,7 @@ describe('Form - Field - Agent - Query', () => {
expect(filterElement).toHaveValue('')
expect(
await wrapper.findByText('Start typing to search…'),
).toBeInTheDocument()
expect(await wrapper.findByText('Start typing to search…')).toBeInTheDocument()
mockAutocompleteSearchAgentQuery({
autocompleteSearchAgent: [testOptions[1]],
@@ -203,9 +197,7 @@ describe('Form - Field - Agent - Query', () => {
await waitForAutocompleteSearchAgentQueryCalls()
expect(
wrapper.queryByText('Start typing to search…'),
).not.toBeInTheDocument()
expect(wrapper.queryByText('Start typing to search…')).not.toBeInTheDocument()
selectOptions = wrapper.getAllByRole('option')
@@ -218,9 +210,7 @@ describe('Form - Field - Agent - Query', () => {
await wrapper.events.clear(filterElement)
expect(
await wrapper.findByText('Start typing to search…'),
).toBeInTheDocument()
expect(await wrapper.findByText('Start typing to search…')).toBeInTheDocument()
mockAutocompleteSearchAgentQuery({
autocompleteSearchAgent: [testOptions[2]],
@@ -230,18 +220,14 @@ describe('Form - Field - Agent - Query', () => {
await waitForAutocompleteSearchAgentQueryCalls()
expect(
wrapper.queryByText('Start typing to search…'),
).not.toBeInTheDocument()
expect(wrapper.queryByText('Start typing to search…')).not.toBeInTheDocument()
selectOptions = wrapper.getAllByRole('option')
expect(selectOptions).toHaveLength(1)
expect(selectOptions[0]).toHaveTextContent(testOptions[2].label)
expect(getByTestId(selectOptions[0], 'common-avatar')).toHaveTextContent(
'fb',
)
expect(getByTestId(selectOptions[0], 'common-avatar')).toHaveTextContent('fb')
})
it('replaces local options with selection', async () => {
@@ -277,9 +263,7 @@ describe('Form - Field - Agent - Query', () => {
expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
expect(wrapper.getByRole('listitem')).toHaveTextContent(
testOptions[0].label,
)
expect(wrapper.getByRole('listitem')).toHaveTextContent(testOptions[0].label)
await wrapper.events.click(wrapper.getByLabelText('Select…'))
@@ -301,7 +285,7 @@ describe('Form - Field - Agent - Query', () => {
const filterElement = wrapper.getByRole('searchbox')
mockAutocompleteSearchAgentQuery({
autocompleteSearchAgent: [...testOptions.slice(0, 1)],
autocompleteSearchAgent: testOptions.slice(0, 1),
})
await wrapper.events.type(filterElement, '*')

View File

@@ -13,15 +13,7 @@ import {
import gql from 'graphql-tag'
import { cloneDeep, escapeRegExp, isEqual, uniqBy } from 'lodash-es'
import { useTemplateRef } from 'vue'
import {
computed,
markRaw,
nextTick,
ref,
toRef,
watch,
type ConcreteComponent,
} from 'vue'
import { computed, markRaw, nextTick, ref, toRef, watch, type ConcreteComponent } from 'vue'
import type { SelectOption } from '#shared/components/CommonSelect/types'
import useValue from '#shared/components/Form/composables/useValue.ts'
@@ -165,10 +157,7 @@ const trimmedFilter = computed(() => {
return props.context.stripFilter(filter.value.trim())
})
const debouncedFilter = refDebounced(
trimmedFilter,
props.context.debounceInterval ?? 500,
)
const debouncedFilter = refDebounced(trimmedFilter, props.context.debounceInterval ?? 500)
const AutocompleteSearchDocument = gql`
${props.context.gqlQuery}
@@ -195,7 +184,7 @@ const autocompleteQueryHandler = new QueryHandler(
input: {
query: debouncedFilter.value || defaultFilter.value || '',
limit: props.context.limit,
...(additionalQueryParams() || {}),
...additionalQueryParams(),
},
}),
() => ({
@@ -218,25 +207,19 @@ if (defaultFilter.value) {
}
const autocompleteQueryResultKey = (
(AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
.selectionSet.selections[0] as SelectionNode & { name: NameNode }
(AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode).selectionSet
.selections[0] as SelectionNode & { name: NameNode }
).name.value
const autocompleteQueryResultOptions = computed<AutoCompleteOption[]>(
(oldValue) => {
const resultOptions =
autocompleteQueryHandler.result().value?.[autocompleteQueryResultKey] ||
[]
const autocompleteQueryResultOptions = computed<AutoCompleteOption[]>((oldValue) => {
const resultOptions = autocompleteQueryHandler.result().value?.[autocompleteQueryResultKey] || []
if (oldValue && isEqual(oldValue, resultOptions)) return oldValue
if (oldValue && isEqual(oldValue, resultOptions)) return oldValue
return resultOptions
},
)
return resultOptions
})
const autocompleteOptions = computed(
() => cloneDeep(autocompleteQueryResultOptions.value) || [],
)
const autocompleteOptions = computed(() => cloneDeep(autocompleteQueryResultOptions.value) || [])
const {
sortedOptions: sortedAutocompleteOptions,
@@ -244,18 +227,12 @@ const {
getSelectedOption: getSelectedAutocompleteOption,
getSelectedOptionIcon: getSelectedAutocompleteOptionIcon,
optionValueLookup: autocompleteOptionValueLookup,
} = useSelectOptions<AutoCompleteOption[]>(
autocompleteOptions,
toRef(props, 'context'),
)
} = useSelectOptions<AutoCompleteOption[]>(autocompleteOptions, toRef(props, 'context'))
const preprocessedAutocompleteOptions = computed(() => {
if (!props.context.autocompleteOptionsPreprocessor)
return sortedAutocompleteOptions.value
if (!props.context.autocompleteOptionsPreprocessor) return sortedAutocompleteOptions.value
return props.context.autocompleteOptionsPreprocessor(
sortedAutocompleteOptions.value,
)
return props.context.autocompleteOptionsPreprocessor(sortedAutocompleteOptions.value)
})
const selectOption = (option: SelectOption, focus = false) => {
@@ -271,9 +248,7 @@ const selectOption = (option: SelectOption, focus = false) => {
appendedOptions.value.push(option as AutoCompleteOption)
}
appendedOptions.value = appendedOptions.value.filter((elem) =>
isCurrentValue(elem.value),
)
appendedOptions.value = appendedOptions.value.filter((elem) => isCurrentValue(elem.value))
if (!focus) return
@@ -346,16 +321,12 @@ const onKeydownFilterInput = (event: KeyboardEvent) => {
})
}
const deaccent = (s: string) =>
s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const deaccent = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const availableOptionsWithMatches = computed(() => {
// Trim and de-accent search keywords and compile them as a case-insensitive regex.
// Make sure to escape special regex characters!
const filterRegex = new RegExp(
escapeRegExp(deaccent(filter.value.trim())),
'i',
)
const filterRegex = new RegExp(escapeRegExp(deaccent(filter.value.trim())), 'i')
return availableOptions.value.map(
(option) =>
@@ -386,8 +357,7 @@ const displayOptions = computed(() => {
})
const suggestedOptionLabel = computed(() => {
if (!filter.value || !availableOptionsWithMatches.value.length)
return undefined
if (!filter.value || !availableOptionsWithMatches.value.length) return undefined
const exactMatches = availableOptionsWithMatches.value.filter(
(option) =>
@@ -462,8 +432,7 @@ const handleToggleDropdown = (event: MouseEvent) => {
}
const OptionIconComponent =
props.context.optionIconComponent ??
(FieldAutoCompleteOptionIcon as ConcreteComponent)
props.context.optionIconComponent ?? (FieldAutoCompleteOptionIcon as ConcreteComponent)
const handleCloseDropdown = (
event: KeyboardEvent,
@@ -563,11 +532,7 @@ useFormBlock(
@blur="context.handlers.blur"
@click.stop="handleToggleDropdown"
>
<div
v-if="hasValue && context.multiple"
class="flex flex-wrap gap-1.5"
role="list"
>
<div v-if="hasValue && context.multiple" class="flex flex-wrap gap-1.5" role="list">
<div
v-for="selectedValue in valueContainer"
:key="selectedValue.toString()"
@@ -578,8 +543,7 @@ useFormBlock(
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
:class="{
'bg-white dark:bg-gray-200': !context.alternativeBackground,
'bg-neutral-100 dark:bg-gray-200':
context.alternativeBackground,
'bg-neutral-100 dark:bg-gray-200': context.alternativeBackground,
}"
>
<CommonIcon

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