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:
@@ -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
|
||||
|
||||
@@ -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`) }
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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})`)
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
580
.eslint-plugin-zammad/pnpm-lock.yaml
generated
580
.eslint-plugin-zammad/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
212
.eslintrc
@@ -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
|
||||
319
.eslintrc.js
319
.eslintrc.js
@@ -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
|
||||
},
|
||||
}
|
||||
35
.git-hooks/pre_commit/eslint.rb
Normal file
35
.git-hooks/pre_commit/eslint.rb
Normal 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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
45
.oxlintrc.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
"
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />`,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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="[
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '*')
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user