Feature: Desktop view - Add Attribute Note Editor

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
This commit is contained in:
Benjamin Scharf 2025-11-10 16:42:05 +01:00
parent 0eee3c5ebf
commit 1e27b560e4
101 changed files with 1545 additions and 381 deletions

View file

@ -4,16 +4,16 @@
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#shared/composables/useKeyboardEventBus/types.ts'
import { useKeyboardEventBus } from '#shared/composables/useKeyboardEventBus/useKeyboardEventBus.ts'
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
import { getRouteIdentifier } from '#desktop/composables/useOverlayContainer.ts'
import CommonDialogActionFooter, {

View file

@ -28,6 +28,11 @@ import { useRoute, useRouter } from 'vue-router'
import type { FormRef } from '#shared/components/Form/types.ts'
import { useForm } from '#shared/components/Form/useForm.ts'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import {
type OrderKeyHandlerConfig,
KeyboardKey,
} from '#shared/composables/useKeyboardEventBus/types.ts'
import { useKeyboardEventBus } from '#shared/composables/useKeyboardEventBus/useKeyboardEventBus.ts'
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
@ -35,11 +40,6 @@ import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
import {
type OrderKeyHandlerConfig,
KeyboardKey,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
import { getRouteIdentifier } from '#desktop/composables/useOverlayContainer.ts'
import CommonFlyoutActionFooter from './CommonFlyoutActionFooter.vue'

View file

@ -466,6 +466,7 @@ const goToChildPage = ({ option, noFocus }: { option: AutoCompleteOption; noFocu
disabled: true,
}"
no-selection-indicator
no-interaction
/>
</div>
<CommonSelectItem
@ -476,6 +477,7 @@ const goToChildPage = ({ option, noFocus }: { option: AutoCompleteOption; noFocu
disabled: true,
}"
no-selection-indicator
no-interaction
/>
</Transition>

View file

@ -16,6 +16,7 @@ const props = defineProps<{
filter?: string
optionIconComponent?: ConcreteComponent
noSelectionIndicator?: boolean
noInteraction?: boolean
}>()
const emit = defineEmits<{
@ -59,8 +60,9 @@ const goToNextPage = (option: AutoCompleteOption, noFocus?: boolean) => {
<template>
<div
:class="{
' hover:bg-blue-600 dark:hover:bg-blue-900 ': !option.disabled,
'hover:bg-blue-600 dark:hover:bg-blue-900 ': !option.disabled,
'hover:bg-blue-800': option.disabled,
'pointer-events-none': noInteraction,
}"
tabindex="0"
:aria-selected="selected"

View file

@ -470,7 +470,7 @@ useFormBlock(
<template>
<div
ref="input"
class="flex h-auto min-h-10 hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
class="flex h-auto min-h-10 hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:-outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
:class="[
context.classes.input,
{

View file

@ -2,13 +2,13 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { nextTick, shallowRef, toRef, ref, defineAsyncComponent, watch } from 'vue'
import { nextTick, shallowRef, toRef, ref, defineAsyncComponent, watch, computed } from 'vue'
import useEditorActionHelper from '#shared/components/Form/fields/FieldEditor/composables/useEditorActionHelper.ts'
import type {
EditorButton,
EditorContentType,
EditorCustomPlugins,
EditorCustomExtensions,
} from '#shared/components/Form/fields/FieldEditor/types.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import type { FieldEditorProps } from '#shared/components/Form/types.ts'
@ -28,13 +28,19 @@ import type { Editor } from '@tiptap/vue-3'
import type { Except } from 'type-fest'
import type { Component } from 'vue'
const props = defineProps<{
editor?: Editor
contentType: EditorContentType
visible: boolean
disabledPlugins: EditorCustomPlugins[]
formContext?: FormFieldContext<FieldEditorProps>
}>()
const props = withDefaults(
defineProps<{
editor?: Editor
contentType: EditorContentType
visible: boolean
disabledExtensions?: EditorCustomExtensions[]
formContext?: FormFieldContext<FieldEditorProps>
isInlineMode?: boolean
}>(),
{
disabledExtensions: () => [],
},
)
defineEmits<{
hide: [boolean?]
@ -54,7 +60,7 @@ const hideActionBarLocally = ref(false)
const { isActive } = useEditorActionHelper(editor)
const { actions } = useEditorActions(editor, props.contentType, props.disabledPlugins)
const { actions } = useEditorActions(editor, props.contentType, props.disabledExtensions)
const { popover, popoverTarget, isOpen, open, close } = usePopover()
@ -115,17 +121,28 @@ watch(
hideActionBarLocally.value = !!showLoader
},
)
const inlineStyle = computed(() => {
if (!props.isInlineMode) return {}
return {
'--top-header-height': '0',
top: '-4.5px', // needed to offset the negative vertical margin of the inline editor
}
})
</script>
<template>
<div
class="sticky top-(--top-header-height) z-30 -order-1 border border-blue-200 bg-neutral-50 ltr:left-0 rtl:right-0 dark:border-gray-700 dark:bg-gray-500"
class="sticky top-(--top-header-height) z-30 -order-1 border-x border-t border-blue-200 bg-neutral-50 ltr:left-0 rtl:right-0 dark:border-gray-700 dark:bg-gray-500"
:style="inlineStyle"
>
<ActionToolbar
v-show="!hideActionBarLocally"
:editor="editor"
:visible="visible"
:is-active="isActive"
:is-inline="isInlineMode"
:actions="actions"
@click-action="handleButtonClick"
@blur="$emit('blur')"

View file

@ -21,6 +21,7 @@ interface Props {
editor?: Editor
visible?: boolean
isActive?: (type: string, attributes?: Record<string, unknown>) => boolean
isInline?: boolean
}
// Doesn't pick up the types for some reason, verify again if resolved after an update
@ -42,6 +43,7 @@ useIntersectionObserver(
const props = withDefaults(defineProps<Props>(), {
visible: true,
size: 'medium',
})
const editor = toRef(props, 'editor')
@ -83,7 +85,7 @@ useEventListener('click', (e) => {
const visibleActions = ref<Map<string, boolean>>(new Map())
const disabledActionNames = ref<Set<string>>(new Set())
const editorActions = useEditorActions(toRef(props, 'editor'), 'text/html', [])
const editorActions = useEditorActions(toRef(props, 'editor'), 'text/html')
const invisibleActions = computed(() =>
editorActions.actions.value
@ -135,7 +137,13 @@ whenever(
tabindex="0"
role="toolbar"
>
<div class="flex h-10.5 flex-wrap gap-1.5 overflow-hidden py-2 ps-2.5 pe-0.5">
<div
class="flex flex-wrap gap-1.5 overflow-hidden"
:class="{
'py-2 ps-2.5 pe-0.5 h-10.5': !isInline,
'py-1 ps-1.5 pe-0.5 h-9': isInline,
}"
>
<ActionButtonWrapper
v-for="(action, index) in actions"
:key="action.name"
@ -171,7 +179,13 @@ whenever(
</template>
</ActionButtonWrapper>
</div>
<div v-if="invisibleActions.length" class="flex gap-1.5 px-2.5 py-2">
<div
v-if="invisibleActions.length"
:class="{
'flex gap-1.5 px-2.5 py-2': !isInline,
'flex gap-1.5 px-1.5 py-1': isInline,
}"
>
<FieldEditorActionMenu
:editor="editor"
content-type="text/html"

View file

@ -146,7 +146,7 @@ defineExpose({ close })
orientation="autoVertical"
placement="start"
hide-arrow
z-index="20"
z-index="50"
@close="$emit('close-popover')"
>
<CommonPopoverMenu

View file

@ -4,10 +4,10 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, onUnmounted } from 'vue'
import useEditorActionHelper from '#shared/components/Form/fields/FieldEditor/composables/useEditorActionHelper.ts'
import { PLUGIN_NAME as AiAssistanceTextToolsName } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { PLUGIN_NAME as KnowledgeBaseMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/KnowledgeBaseSuggestion.ts'
import { PLUGIN_NAME as TextModuleMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/TextModuleSuggestion.ts'
import { PLUGIN_NAME as UserMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import { EXTENSION_NAME as AiAssistanceTextToolsName } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME as KnowledgeBaseMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/KnowledgeBaseSuggestion.ts'
import { EXTENSION_NAME as TextModuleMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/TextModuleSuggestion.ts'
import { EXTENSION_NAME as UserMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import AiAssistantTextTools from '#shared/components/Form/fields/FieldEditor/features/ai-assistant-text-tools/AiAssistantTextTools/AiAssistantTextTools.vue'
import FieldEditorColorMenu from '#shared/components/Form/fields/FieldEditor/features/color-picker/EditorColorMenu.vue'
import type {
@ -27,7 +27,7 @@ import type { ShallowRef } from 'vue'
export default function useEditorActions(
editor: ShallowRef<Editor | undefined>,
contentType: EditorContentType,
disabledPlugins: string[],
disabledExtensions: string[] = [],
) {
const { focused, isActive } = useEditorActionHelper(editor)
@ -303,7 +303,7 @@ export default function useEditorActions(
},
{
id: getUuid(),
name: 'table',
name: 'tableKit',
contentType: ['text/html'],
label: __('Insert table'),
icon: 'editor-table',
@ -318,7 +318,7 @@ export default function useEditorActions(
const actions = computed(() =>
getActionsList().filter((action) => {
if (disabledPlugins.includes(action.name)) return false
if (disabledExtensions.includes(action.name)) return false
if (action.show && !action.show(applicationConfig.value)) return false

View file

@ -193,7 +193,7 @@ const ensureGranularOrFullAccess = (
<template>
<output
:id="context.id"
class="flex w-full flex-col space-y-2 rounded-lg p-2 focus:outline focus:outline-1 focus:outline-offset-1 focus:outline-blue-800 hover:focus:outline-blue-800"
class="flex w-full flex-col space-y-2 rounded-lg p-2 focus:outline-1 focus:-outline-offset-1 focus:outline-blue-800 hover:focus:outline-blue-800"
:class="context.classes.input"
:name="context.node.name"
role="list"

View file

@ -174,7 +174,7 @@ setupMissingOrDisabledOptionHandling()
<template>
<div
ref="input"
class="flex h-auto min-h-10 hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
class="flex h-auto min-h-10 hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:-outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
:class="[
context.classes.input,
{

View file

@ -226,7 +226,7 @@ setupMissingOrDisabledOptionHandling()
<template>
<div
ref="input"
class="flex h-auto min-h-10 hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
class="flex h-auto min-h-10 hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:-outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
:class="[
context.classes.input,
{

View file

@ -44,6 +44,7 @@ const renderOrganizationPopover = (props?: Partial<Props>, isAgent = true) => {
organization: props?.organization ?? dummyOrganization,
},
router: true,
form: true,
})
}

View file

@ -95,8 +95,8 @@ defineExpose({
class="flex justify-center opacity-0 focus-within:opacity-100 hover:opacity-100"
:class="
{
horizontal: 'h-[12px] w-full',
vertical: 'h-full w-[12px]',
horizontal: 'h-3 w-full',
vertical: 'h-full w-3]',
}[orientation]
"
>

View file

@ -102,8 +102,6 @@ export const useResizeLine = (
// Do not react on double click event.
if (e.detail > 1) return
e.preventDefault()
isResizing.value = true
addEventListeners()

View file

@ -4,15 +4,15 @@
import { useTemplateRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#shared/composables/useKeyboardEventBus/types.ts'
import { useKeyboardEventBus } from '#shared/composables/useKeyboardEventBus/useKeyboardEventBus.ts'
import { useOnEmitter } from '#shared/composables/useOnEmitter.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
const searchValue = defineModel<string>()

View file

@ -7,7 +7,6 @@ import {
NotificationTypes,
useNotifications,
} from '#shared/components/CommonNotifications/index.ts'
import { PLUGIN_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { transformEditorHtml } from '#shared/components/Form/fields/FieldEditor/utils.ts'
import Form from '#shared/components/Form/Form.vue'
import type { FormSubmitData } from '#shared/components/Form/types.ts'
@ -124,24 +123,7 @@ const formSchema = defineFormSchema([
screen: 'edit',
object: EnumObjectManagerObjects.TicketArticle,
props: {
// Disable all the advanced features for now.
meta: {
mentionText: {
disabled: true,
},
mentionKnowledgeBase: {
disabled: true,
},
mentionUser: {
disabled: true,
},
[TEXT_TOOL_PLUGIN_NAME]: {
disabled: true,
},
image: {
disabled: true,
},
},
mode: ['note'],
},
required: true,
},

View file

@ -70,7 +70,7 @@ const goToUserProfile = () => {
}"
:object="user!"
:attributes="viewScreenAttributes"
:skip-attributes="['firstname', 'lastname', 'organization_id']"
:skip-attributes="['firstname', 'lastname', 'organization_id', 'organization_ids']"
/>
<CommonSimpleEntityList

View file

@ -78,6 +78,7 @@ const renderUserPopover = (props?: Partial<Props>, isAgent = true, isSystemUser
user: isSystemUser ? systemUser : (props?.user ?? dummyUser),
},
router: true,
form: true,
})
}

View file

@ -1,13 +1,11 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
// import { useReactivate } from '#desktop/composables/useReactivate.ts'
import { onMounted, ref } from 'vue'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import { useReactivate } from '#desktop/composables/useReactivate.ts'
import { useReactivate } from '#shared/composables/useReactivate.ts'
describe('useReactivate', () => {
it('should call callbacks appropriate', () => {

View file

@ -87,6 +87,7 @@ export const initializeFormFields = () => {
},
input: {
container: 'px-2.5 py-2',
inlineContainer: 'px-1.5! py-1!',
},
})

View file

@ -6,7 +6,7 @@ import type { FormThemeClasses, FormThemeExtension } from '#shared/types/form.ts
const innerInvalidAndErrorClasses = () => {
const innerInvalidClasses =
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 dark:hover:formkit-invalid:outline-red-500'
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:-outline-offset-1 formkit-invalid:outline-red-500 dark:hover:formkit-invalid:outline-red-500'
const innerErrorsClasses = innerInvalidClasses.replace(/invalid/g, 'errors')
@ -19,13 +19,13 @@ const textInputClasses = (classes: Classes = {}) =>
input:
'grow bg-transparent px-2.5 py-2 placeholder:text-stone-200 read-only:text-stone-200 dark:placeholder:text-neutral-500 dark:read-only:text-neutral-500',
label: 'mb-1 block text-sm text-gray-100 dark:text-neutral-400',
inner: `flex h-10 w-full items-center bg-blue-200 text-black focus-within:outline focus-within:outline-1 focus-within:outline-offset-1 focus-within:outline-blue-800 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 hover:focus-within:outline-blue-800 dark:bg-gray-700 dark:text-white dark:hover:outline-blue-900 dark:hover:focus-within:outline-blue-800 ${innerInvalidAndErrorClasses()}`,
inner: `flex h-10 w-full items-center bg-blue-200 text-black focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-blue-800 hover:outline hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 hover:focus-within:outline-blue-800 dark:bg-gray-700 dark:text-white dark:hover:outline-blue-900 dark:hover:focus-within:outline-blue-800 ${innerInvalidAndErrorClasses()}`,
})
const selectInputClasses = (classes: Classes = {}) =>
extendClasses(classes, {
inner:
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:outline-offset-1 formkit-errors:outline-red-500 w-full',
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:-outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:-outline-offset-1 formkit-errors:outline-red-500 w-full',
})
export const getCoreDesktopClasses: FormThemeExtension = (classes: FormThemeClasses) => {
@ -39,9 +39,9 @@ export const getCoreDesktopClasses: FormThemeExtension = (classes: FormThemeClas
messages: 'formkit-invalid:text-red-500 formkit-errors:text-red-500 mt-1',
help: 'mt-1 text-stone-200 dark:text-neutral-500',
prefixIcon:
'relative flex h-4 w-4 items-center justify-center fill-current text-stone-200 hover:text-black focus-visible:rounded-xs focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 ltr:ml-2.5 rtl:mr-2.5 dark:text-neutral-500 dark:hover:text-white',
'relative flex h-4 w-4 items-center justify-center fill-current text-stone-200 hover:text-black focus-visible:rounded-xs focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 ltr:ml-2.5 rtl:mr-2.5 dark:text-neutral-500 dark:hover:text-white',
suffixIcon:
'relative flex h-4 w-4 items-center justify-center fill-current text-stone-200 hover:text-black focus-visible:rounded-xs focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 ltr:mr-2.5 rtl:ml-2.5 dark:text-neutral-500 dark:hover:text-white',
'relative flex h-4 w-4 items-center justify-center fill-current text-stone-200 hover:text-black focus-visible:rounded-xs focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 ltr:mr-2.5 rtl:ml-2.5 dark:text-neutral-500 dark:hover:text-white',
}),
form: extendClasses(classes.form, {
messages: 'mb-2.5 flex-wrap space-y-2',
@ -66,7 +66,7 @@ export const getCoreDesktopClasses: FormThemeExtension = (classes: FormThemeClas
inner: 'w-5 h-5 flex justify-center items-center ltr:mr-1 rtl:ml-1 formkit-label-hidden:m-0',
input: 'peer appearance-none focus:outline-hidden focus:ring-0 focus:ring-offset-0',
decorator:
'w-3 h-3 relative border peer-hover:border-blue-600 dark:peer-hover:border-blue-900 peer-focus:border-blue-800 peer-focus:outline peer-focus:outline-1 peer-focus:outline-offset-1 peer-focus:outline-blue-800 rounded-xs bg-transparent peer-hover:text-blue-600 dark:peer-hover:text-blue-900 peer-focus:text-blue-800 formkit-checked:peer-hover:border-blue-600 dark:formkit-checked:peer-hover:border-blue-900 formkit-checked:peer-focus:border-blue-800 formkit-checked:peer-focus:outline-blue-800 formkit-checked:peer-hover:text-blue-600 dark:formkit-checked:peer-hover:text-blue-900 formkit-checked:peer-focus:text-blue-800',
'w-3 h-3 relative border peer-hover:border-blue-600 dark:peer-hover:border-blue-900 peer-focus:border-blue-800 peer-focus:outline peer-focus:outline-1 peer-focus:-outline-offset-1 peer-focus:outline-blue-800 rounded-xs bg-transparent peer-hover:text-blue-600 dark:peer-hover:text-blue-900 peer-focus:text-blue-800 formkit-checked:peer-hover:border-blue-600 dark:formkit-checked:peer-hover:border-blue-900 formkit-checked:peer-focus:border-blue-800 formkit-checked:peer-focus:outline-blue-800 formkit-checked:peer-hover:text-blue-600 dark:formkit-checked:peer-hover:text-blue-900 formkit-checked:peer-focus:text-blue-800',
decoratorIcon:
'absolute invisible formkit-is-checked:visible -top-px ltr:-left-px rtl:-right-px',
},
@ -75,7 +75,7 @@ export const getCoreDesktopClasses: FormThemeExtension = (classes: FormThemeClas
}),
imageUpload: extendClasses(classes.imageUpload, {
inner:
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:-outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:-outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
}),
select: selectInputClasses(classes.select),
treeselect: selectInputClasses(classes.treeselect),
@ -91,18 +91,18 @@ export const getCoreDesktopClasses: FormThemeExtension = (classes: FormThemeClas
}),
groupPermissions: extendClasses(classes.groupPermissions, {
inner:
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:-outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:-outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
}),
editor: extendClasses(classes.editor, {
wrapper: 'max-w-full',
input: 'min-h-[76px] text-sm text-black outline-hidden dark:text-white',
inner: `rounded-t-none bg-blue-200 focus-within:outline focus-within:outline-1 focus-within:outline-offset-1 focus-within:outline-blue-800 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 focus-within:hover:outline-blue-800 focus-visible:outline-1 dark:bg-gray-700 dark:hover:outline-blue-900 dark:focus-within:hover:outline-blue-800 ${innerInvalidAndErrorClasses()}`,
inner: 'group rounded-t-none bg-blue-200 dark:bg-gray-700',
}),
// TODO: check...
file: extendClasses(classes.file, {
input: 'p-1',
inner:
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:-outline-offset-1 formkit-invalid:outline-red-500 formkit-errors:outline formkit-errors:outline-1 formkit-errors:-outline-offset-1 formkit-errors:outline-red-500 w-full bg-blue-200 dark:bg-gray-700',
messages: 'px-4',
}),
}

View file

@ -1,8 +1,10 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { initButtonGroup } from '#shared/components/ObjectAttributes/attributes/AttributeRichtext/initializeRichtextButtons.ts'
import { setupCommonVisualConfig } from '#shared/composables/useSharedVisualConfig.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonInlineEditButtons from '#desktop/components/CommonInlineEditButtons/CommonInlineEditButtons.vue'
import CommonObjectAttribute from '#desktop/components/CommonObjectAttribute/CommonObjectAttribute.vue'
import CommonObjectAttributeContainer from '#desktop/components/CommonObjectAttribute/CommonObjectAttributeContainer.vue'
@ -16,7 +18,7 @@ export const initializeDesktopVisuals = () => {
link: 'text-sm',
},
},
// TODO: should be moved to mobile only or renamed completley.
// TODO: should be moved to mobile only or renamed completely.
tooltip: {
type: 'inline',
component: () => null,
@ -31,4 +33,6 @@ export const initializeDesktopVisuals = () => {
buttonComponent: CommonButton,
},
})
initButtonGroup(CommonInlineEditButtons)
}

View file

@ -5,7 +5,7 @@ import { isEqual } from 'lodash-es'
import { computed, markRaw, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { PLUGIN_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME as TEXT_TOOL_EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import Form from '#shared/components/Form/Form.vue'
import type { FormSubmitData } from '#shared/components/Form/types.ts'
import { useForm } from '#shared/components/Form/useForm.ts'
@ -166,7 +166,7 @@ const formSchema = defineFormSchema([
mentionKnowledgeBase: {
attachmentsNodeName: 'attachments',
},
[TEXT_TOOL_PLUGIN_NAME]: {
[TEXT_TOOL_EXTENSION_NAME]: {
groupNodeName: 'group_id',
ticketNodeName: 'ticket_id',
customerNodeName: 'customer_id',

View file

@ -17,7 +17,7 @@ export const useTicketEditTitle = (ticketId: ComputedRef<string>) => {
return mutationUpdate
.send({
ticketId: ticketId.value,
input: { title },
title: title,
})
.then(() => {
notify({

View file

@ -68,7 +68,16 @@ onBeforeUnmount(() => {
/>
</div>
<div ref="scroll-container" class="flex h-full flex-col gap-3 overflow-y-auto p-3">
<!-- NB: --top-header-height is used for the editor action bar sticky calculation. -->
<!-- +7 * --spacing => sidebar header height -->
<!-- +3 * --spacing => sidebar content padding -->
<!-- -14 * --spacing => bottom bar height -->
<!-- +3px => border + offset + outline -->
<div
ref="scroll-container"
class="flex h-full flex-col gap-3 overflow-y-auto p-3"
:style="{ '--top-header-height': 'calc(var(--spacing) * (7 + 3 - 14) + 3px)' }"
>
<slot />
</div>
</template>

View file

@ -70,9 +70,7 @@ watch(customerId, (newValue) => {
})
// On initial setup we show the sidebar if customerId is present.
if (customerId.value) {
emit('show')
}
if (customerId.value) emit('show')
</script>
<template>

View file

@ -6,6 +6,7 @@ import { computed, type ComputedRef } from 'vue'
import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
import { useUserNoteUpdateMutation } from '#shared/entities/user/graphql/mutations/noteUpdate.api.ts'
import { EnumTicketStateTypeCategory, type Organization, type User } from '#shared/graphql/types.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import { normalizeEdges } from '#shared/utils/helpers.ts'
@ -89,7 +90,8 @@ const actions = computed<MenuItem[]>(() => [
<ObjectAttributes
:attributes="objectAttributes"
:object="customer"
:skip-attributes="['firstname', 'lastname', 'organization_id']"
:skip-attributes="['firstname', 'lastname', 'organization_id', 'organization_ids']"
:inline-editable="{ note: useUserNoteUpdateMutation }"
/>
<CommonSimpleEntityList

View file

@ -36,6 +36,7 @@ const renderTicketSidebarCustomer = async (
},
},
router: true,
form: true,
provide: [
[
TICKET_KEY,

View file

@ -3,6 +3,7 @@
<script lang="ts" setup>
import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import { useOrganizationNoteUpdateMutation } from '#shared/entities/organization/graphql/mutations/noteUpdate.api.ts'
import type { Organization, User } from '#shared/graphql/types.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import { normalizeEdges } from '#shared/utils/helpers.ts'
@ -56,6 +57,9 @@ const actions: MenuItem[] = [
:object="organization"
:attributes="objectAttributes"
:skip-attributes="['name', 'vip', 'active']"
:inline-editable="{
note: useOrganizationNoteUpdateMutation,
}"
/>
<CommonSimpleEntityList

View file

@ -36,6 +36,7 @@ const renderTicketSidebarOrganization = async (
teleport: true,
},
},
form: true,
...options,
})

View file

@ -4,6 +4,7 @@
import { storeToRefs } from 'pinia'
import { computed, type EffectScope, effectScope, ref, watch } from 'vue'
import { useReactivate } from '#shared/composables/useReactivate.ts'
import { useTicketArticleUpdatesSubscription } from '#shared/entities/ticket/graphql/subscriptions/ticketArticlesUpdates.api.ts'
import {
type AiAnalyticsMetadata,
@ -15,7 +16,6 @@ import { MutationHandler, SubscriptionHandler } from '#shared/server/apollo/hand
import { useApplicationStore } from '#shared/stores/application.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import { useReactivate } from '#desktop/composables/useReactivate.ts'
import TicketSidebarSummaryContent from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue'
import {
type SummaryConfig,

View file

@ -8,7 +8,7 @@ import useEditorActionHelper from '#shared/components/Form/fields/FieldEditor/co
import type {
EditorButton,
EditorContentType,
EditorCustomPlugins,
EditorCustomExtensions,
} from '#shared/components/Form/fields/FieldEditor/types.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import type { FieldEditorProps } from '#shared/components/Form/types.ts'
@ -25,13 +25,18 @@ import type { Editor } from '@tiptap/vue-3'
import type { Except } from 'type-fest'
import type { Component } from 'vue'
const props = defineProps<{
editor?: Editor
contentType: EditorContentType
visible: boolean
disabledPlugins: EditorCustomPlugins[]
formContext?: FormFieldContext<FieldEditorProps>
}>()
const props = withDefaults(
defineProps<{
editor?: Editor
contentType: EditorContentType
visible: boolean
disabledExtensions?: EditorCustomExtensions[]
formContext?: FormFieldContext<FieldEditorProps>
}>(),
{
disabledExtensions: () => [],
},
)
defineEmits<{
hide: [boolean?]
@ -51,7 +56,7 @@ const hideActionBarLocally = ref(false)
const { isActive } = useEditorActionHelper(editor)
const { actions } = useEditorActions(editor, props.contentType, props.disabledPlugins)
const { actions } = useEditorActions(editor, props.contentType, props.disabledExtensions)
const subMenuPopupContent = shallowRef<Component | Except<EditorButton, 'subMenu'>[]>()

View file

@ -4,9 +4,9 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, onUnmounted } from 'vue'
import useEditorActionHelper from '#shared/components/Form/fields/FieldEditor/composables/useEditorActionHelper.ts'
import { PLUGIN_NAME as KnowledgeBaseMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/KnowledgeBaseSuggestion.ts'
import { PLUGIN_NAME as TextModuleMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/TextModuleSuggestion.ts'
import { PLUGIN_NAME as UserMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import { EXTENSION_NAME as KnowledgeBaseMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/KnowledgeBaseSuggestion.ts'
import { EXTENSION_NAME as TextModuleMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/TextModuleSuggestion.ts'
import { EXTENSION_NAME as UserMentionName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import AiAssistantTextTools from '#shared/components/Form/fields/FieldEditor/features/ai-assistant-text-tools/AiAssistantTextTools/AiAssistantTextTools.vue'
import FieldEditorColorMenu from '#shared/components/Form/fields/FieldEditor/features/color-picker/EditorColorMenu.vue'
import type {
@ -26,7 +26,7 @@ import type { ShallowRef } from 'vue'
export default function useEditorActions(
editor: ShallowRef<Editor | undefined>,
contentType: EditorContentType,
disabledPlugins: string[],
disabledExtensions: string[] = [],
) {
const { focused, isActive } = useEditorActionHelper(editor)
@ -324,7 +324,7 @@ export default function useEditorActions(
},
{
id: getUuid(),
name: 'table',
name: 'tableKit',
contentType: ['text/html'],
label: __('Insert table'),
icon: 'editor-table',
@ -340,7 +340,7 @@ export default function useEditorActions(
const actions = computed(() =>
getActionsList().filter((action) => {
if (disabledPlugins.includes(action.name)) return false
if (disabledExtensions.includes(action.name)) return false
if (action.show && !action.show(applicationConfig.value)) return false

View file

@ -1,6 +1,7 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { FormSchemaField } from '#shared/components/Form/types.ts'
import { useUserUpdateMutation } from '#shared/entities/user/graphql/mutations/update.api.ts'
import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
import type { UserQuery } from '#shared/graphql/types.ts'
import { EnumFormUpdaterId, EnumObjectManagerObjects } from '#shared/graphql/types.ts'
@ -8,7 +9,6 @@ import { useApplicationStore } from '#shared/stores/application.ts'
import type { ConfidentTake } from '#shared/types/utils.ts'
import { useDialogObjectForm } from '#mobile/components/CommonDialogObjectForm/useDialogObjectForm.ts'
import { useUserUpdateMutation } from '#mobile/pages/user/graphql/mutations/update.api.ts'
export const useUserEdit = () => {
const dialog = useDialogObjectForm('user-edit', EnumObjectManagerObjects.User)

View file

@ -81,6 +81,7 @@ export const initializeFormFields = () => {
},
input: {
container: 'p-2',
inlineContainer: '',
},
})

View file

@ -5,7 +5,7 @@ import { useEventListener } from '@vueuse/core'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { PLUGIN_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import Form from '#shared/components/Form/Form.vue'
import type { FormSubmitData, FormSchemaNode } from '#shared/components/Form/types.ts'
import { useForm } from '#shared/components/Form/useForm.ts'

View file

@ -67,7 +67,7 @@ const ticketsData = computed(() => getTicketData(user.value))
<ObjectAttributes
:attributes="objectAttributes"
:object="user"
:skip-attributes="['firstname', 'lastname']"
:skip-attributes="['firstname', 'lastname', 'organization_ids']"
:always-show-after-fields="user.policy.update"
>
<template v-if="user.policy.update" #after-fields>

View file

@ -123,7 +123,7 @@ const ticketsData = computed(() => {
<ObjectAttributes
:attributes="objectAttributes"
:object="user"
:skip-attributes="['firstname', 'lastname']"
:skip-attributes="['firstname', 'lastname', 'organization_ids']"
/>
<CommonOrganizationsList

View file

@ -2,23 +2,27 @@
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import { type Editor } from '@tiptap/vue-3'
import { useEventListener } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, toRef, useTemplateRef, watch } from 'vue'
import useValue from '#shared/components/Form/composables/useValue.ts'
import { useAttachments } from '#shared/components/Form/fields/FieldEditor/composables/useAttachments.ts'
import { useSignatureHandling } from '#shared/components/Form/fields/FieldEditor/composables/useSignatureHandling.ts'
import { PLUGIN_NAME as userMentionPluginName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import { EXTENSION_NAME as userMentionExtensionName } from '#shared/components/Form/fields/FieldEditor/extensions/UserMention.ts'
import {
imageExtensionName,
tableKitExtensionName,
getCustomExtensions,
getHtmlExtensions,
getPlainExtensions,
PlaceholderExtensionName,
} from '#shared/components/Form/fields/FieldEditor/extensions.ts'
import FieldEditorTableMenu from '#shared/components/Form/fields/FieldEditor/features/table/EditorTableMenu.vue'
import FieldEditorFooter from '#shared/components/Form/fields/FieldEditor/FieldEditorFooter.vue'
import type {
EditorContentType,
EditorCustomPlugins,
EditorCustomExtensions,
FieldEditorContext,
FieldEditorProps,
} from '#shared/components/Form/fields/FieldEditor/types.ts'
@ -27,57 +31,88 @@ import {
getEditorComponents,
} from '#shared/components/Form/initializeFieldEditor.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import { getButtonGroup } from '#shared/components/ObjectAttributes/attributes/AttributeRichtext/initializeRichtextButtons.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import { htmlCleanup } from '#shared/utils/htmlCleanup.ts'
import { useInlineMode } from './useInlineMode.ts'
interface Props {
context: FormFieldContext<FieldEditorProps>
}
const props = defineProps<Props>()
const placeholder = props.context.placeholder
? props.context.placeholder
: props.context.inline
? __('Click to edit…')
: ''
const getEditorContent = (editor: Editor, type: EditorContentType) => {
if (type === 'text/plain') return editor.getText()
const content = editor.getHTML()
return editor.isEmpty ? '' : content
}
const actionBarComponent = getEditorComponents().actionBar
const reactiveContext = toRef(props, 'context')
const { currentValue } = useValue(reactiveContext)
const disabledPlugins = Object.entries(props.context.meta || {})
const disabledExtensions = Object.entries(props.context.meta || {})
.filter(([, value]) => value.disabled)
.map(([key]) => key as EditorCustomPlugins)
.map(([key]) => key as EditorCustomExtensions | string)
const disableExtension = (extensionName: EditorCustomExtensions | string) => {
if (disabledExtensions.includes(extensionName)) return
disabledExtensions.push(extensionName)
}
const contentType = computed<EditorContentType>(() => props.context.contentType || 'text/html')
const isPlainText = computed(() => contentType.value === 'text/plain')
// remove user mention in plain text mode and inline images
// Disable user mention and image extensions in plain text mode.
if (isPlainText.value) {
disabledPlugins.push(userMentionPluginName, 'image')
disableExtension(userMentionExtensionName)
disableExtension(imageExtensionName)
}
const { hasPermission } = useSessionStore()
const customExtensions = getCustomExtensions(reactiveContext)
// Disable all custom extensions and tables for the basic set.
if (props.context.extensionSet === 'basic') {
customExtensions.forEach((extension) =>
disableExtension(extension.name as EditorCustomExtensions),
)
disableExtension(tableKitExtensionName)
}
if (placeholder === '') disableExtension(PlaceholderExtensionName)
// TODO: extensions are in general not reactive in TipTap, we need to check if all things are working as expected.
// TODO: Maybe we need a re-creation of the editor in some edge cases... plain <-> html (check against simple channels...)
const availableCustomExtensions = computed(() =>
customExtensions.filter((extension) => {
const editorExtensions = computed(() => {
const baseExtensions = isPlainText.value
? getPlainExtensions(placeholder)
: getHtmlExtensions(placeholder)
const availableExtensions = [...baseExtensions, ...customExtensions].filter((extension) => {
const { name, options } = extension
if (disabledPlugins.includes(name as EditorCustomPlugins)) {
return false
}
if (options?.permission && !hasPermission(options.permission)) {
return false
}
if (disabledExtensions.includes(name as EditorCustomExtensions)) return false
if (options?.permission && !hasPermission(options.permission)) return false
return true
}),
)
})
const editorExtensions = computed(() => {
const baseExtensions = isPlainText.value ? getPlainExtensions() : getHtmlExtensions()
return [...baseExtensions, ...availableCustomExtensions.value]
return availableExtensions
})
const showActionBar = ref(false)
@ -150,8 +185,7 @@ const editor = useEditor({
? htmlCleanup(currentValue.value)
: currentValue.value,
onUpdate({ editor }) {
const content = isPlainText.value ? editor.getText() : editor.getHTML()
const value = content === '<p></p>' ? '' : content
const value = getEditorContent(editor as Editor, contentType.value)
props.context.node.input(value)
if (!VITE_TEST_MODE) return
@ -159,6 +193,10 @@ const editor = useEditor({
},
onFocus() {
showActionBar.value = true
if (!isInlineMode.value) return
isEditing.value = true
},
onBlur() {
props.context.handlers.blur()
@ -261,7 +299,7 @@ const editorCustomContext = {
getEditorValue: (type: EditorContentType) => {
if (!editor.value) return ''
return type === 'text/plain' ? editor.value.getText() : editor.value.getHTML()
return getEditorContent(editor.value, type)
},
addSignature,
removeSignature,
@ -286,17 +324,64 @@ onMounted(() => {
})
const classes = getFieldEditorClasses()
const buttonGroup = getButtonGroup()
const wrapperElement = useTemplateRef('wrapper')
const {
isInlineMode,
isSubmitting,
isEditing,
onWrapperClick,
handleCancel,
handleChange,
containerInlineDesktopClasses,
wrapperInlineDesktopClasses,
} = useInlineMode(toRef(props, 'context'), wrapperElement)
</script>
<template>
<!-- TODO: questionable usability - it moves, when new line is added -->
<div class="flex flex-col">
<div :class="classes.input.container">
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<div
ref="wrapper"
:role="isInlineMode ? 'button' : undefined"
tabindex="-1"
class="flex flex-col relative"
:class="[
containerInlineDesktopClasses,
{
'show-action-bar': isEditing,
},
]"
@click="onWrapperClick"
@keydown.space="onWrapperClick"
>
<!-- Check if SR label is present on FormKit level labelSrOnly must be true -->
<CommonLabel
v-if="context.label && isInlineMode && !isEditing"
class="text-stone-200! absolute top-4 rtl:right-1 ltr:left-1"
size="small"
>
{{ context.label }}
</CommonLabel>
<div
:class="[
classes.input.container,
wrapperInlineDesktopClasses,
{
[classes.input.inlineContainer]: isInlineMode,
},
]"
>
<EditorContent
class="text-base ltr:text-left rtl:text-right"
class="text-base ltr:text-left rtl:text-right cursor-text"
data-test-id="field-editor"
:editor="editor"
/>
<FieldEditorFooter
v-if="context.meta?.footer && !context.meta.footer.disabled && editor"
:footer="context.meta.footer"
@ -308,15 +393,33 @@ const classes = getFieldEditorClasses()
:editor="editor"
:content-type="contentType"
/>
<!-- BUTTON group is only implemented in DESKTOP -->
<div v-if="isInlineMode && buttonGroup" class="flex justify-end sticky bottom-0">
<component
:is="buttonGroup"
:class="{ invisible: !isEditing }"
:submit-disabled="isSubmitting"
:cancel-disabled="isSubmitting"
@click.stop
@cancel="handleCancel"
@submit="handleChange"
/>
</div>
</div>
<component
:is="actionBarComponent"
:class="{
invisible: isInlineMode && !isEditing,
}"
:editor="editor"
:content-type="contentType"
:visible="showActionBar"
:disabled-plugins="disabledPlugins"
:disabled-extensions="disabledExtensions"
:form-context="reactiveContext"
:is-editing="isEditing"
:is-inline-mode="isInlineMode"
@hide="showActionBar = false"
@blur="focusEditor"
/>
@ -352,6 +455,28 @@ const classes = getFieldEditorClasses()
margin: 0;
}
}
p.is-editor-empty:first-child::before {
/* DESKTOP ONLY CLASS */
color: var(--color-neutral-400);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
font-size: var(--text-sm);
}
&:focus p.is-editor-empty:first-child::before {
content: none;
}
}
.show-action-bar {
.tiptap {
p:first-child::before {
content: none;
}
}
}
.tableWrapper {

View file

@ -5,6 +5,7 @@ import CharacterCount from '@tiptap/extension-character-count'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Color from '@tiptap/extension-color'
import Paragraph from '@tiptap/extension-paragraph'
import Placeholder from '@tiptap/extension-placeholder'
import { TableKit } from '@tiptap/extension-table'
import { TextStyle } from '@tiptap/extension-text-style'
import UniqueID from '@tiptap/extension-unique-id'
@ -36,9 +37,13 @@ import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import type { Extensions } from '@tiptap/core'
import type { Ref } from 'vue'
export const imageExtensionName = Image.name
export const tableKitExtensionName = TableKit.name
export const PlaceholderExtensionName = Placeholder.name
export const lowlight = createLowlight(common)
export const getPlainExtensions = (): Extensions => [
export const getPlainExtensions = (placeholder = ''): Extensions => [
StarterKit.configure({
blockquote: false,
bold: false,
@ -64,9 +69,12 @@ export const getPlainExtensions = (): Extensions => [
UniqueID.configure({
types: ['paragraph', 'heading'],
}),
Placeholder.configure({
placeholder,
}),
]
export const getHtmlExtensions = (): Extensions => [
export const getHtmlExtensions = (placeholder = ''): Extensions => [
StarterKit.configure({
blockquote: false,
paragraph: false,
@ -123,6 +131,9 @@ export const getHtmlExtensions = (): Extensions => [
UniqueID.configure({
types: ['paragraph', 'heading'],
}),
Placeholder.configure({
placeholder,
}),
]
export const getCustomExtensions = (

View file

@ -77,7 +77,7 @@ const createLoaderHandler = (editor: Editor) => ({
const getFormRenderContext = async (context: Ref<FormFieldContext<FieldEditorProps>>) => {
const { formId, ticketId, meta: editorMeta } = context.value
const meta = editorMeta?.[PLUGIN_NAME] || {}
const meta = editorMeta?.[EXTENSION_NAME] || {}
let { customerId, groupId, organizationId } = context.value
@ -172,15 +172,15 @@ const executeTextModification = async (
}
}
export const PLUGIN_NAME = 'aiAssistantTextTools'
export const EXTENSION_NAME = 'aiAssistantTextTools'
export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
const { formId, ticketId, meta: editorMeta } = context.value
const meta = editorMeta?.[PLUGIN_NAME] || {}
const meta = editorMeta?.[EXTENSION_NAME] || {}
let scope = effectScope()
return Extension.create({
name: PLUGIN_NAME,
name: EXTENSION_NAME,
addStorage() {
return {
showAiTextLoader: false,
@ -223,7 +223,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
if (!editor) return
editor.emit('toggle-visibility', {
name: PLUGIN_NAME,
name: EXTENSION_NAME,
active: !!aiAssistanceTextToolsList.length,
})
})

View file

@ -17,7 +17,8 @@ import type { FieldEditorProps, MentionKnowledgeBaseItem } from '../types.ts'
import type { CommandProps } from '@tiptap/core'
import type { Ref } from 'vue'
export const PLUGIN_NAME = 'mentionKnowledgeBase'
export const EXTENSION_NAME = 'mentionKnowledgeBase'
const ACTIVATOR = '??'
export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
@ -37,7 +38,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
)
return Mention.extend({
name: PLUGIN_NAME,
name: EXTENSION_NAME,
addCommands: () => ({
openKnowledgeBaseMention:
() =>
@ -58,7 +59,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
type: 'knowledge-base',
async insert(props: MentionKnowledgeBaseItem) {
const { meta: editorMeta = {}, formId } = context.value
const meta = editorMeta[PLUGIN_NAME] || {}
const meta = editorMeta[EXTENSION_NAME] || {}
const result = await translateHandler.send({
translationId: props.id,

View file

@ -6,7 +6,7 @@ import { type Editor, VueRenderer } from '@tiptap/vue-3'
// We can't async load LinkForm, otherwise initially VueRenderer will not render it
import LinkForm from '#shared/components/Form/fields/FieldEditor/features/link/LinkForm.vue'
import { PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
import { EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
import {
getActiveNodeOrMark,
setFloatingPopover,
@ -19,7 +19,7 @@ const appName = useAppName()
export default Link.extend({
inclusive: false, // prevents bad UX to leave setting a link on the same line.
name: PLUGIN_NAME,
name: EXTENSION_NAME,
addAttributes() {
const attributes = {
@ -126,7 +126,7 @@ export default Link.extend({
return editor.commands.closeLinkForm()
},
handleClick() {
const isLinkClicked = editor.getAttributes(PLUGIN_NAME)
const isLinkClicked = editor.getAttributes(EXTENSION_NAME)
editor.commands.closeLinkForm()
if ('href' in isLinkClicked) editor.commands.openLinkForm()

View file

@ -15,7 +15,8 @@ import { useTextModuleSuggestionsLazyQuery } from '../graphql/queries/textModule
import type { FieldEditorProps, MentionTextItem } from '../types.ts'
import type { CommandProps } from '@tiptap/core'
export const PLUGIN_NAME = 'mentionText'
export const EXTENSION_NAME = 'mentionText'
const ACTIVATOR = '::'
const LIMIT_QUERY_MODULES = 10
@ -26,7 +27,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
const getTextModules = async (query: string) => {
const { meta: editorMeta = {}, formId } = context.value
const meta = editorMeta[PLUGIN_NAME] || {}
const meta = editorMeta[EXTENSION_NAME] || {}
const { ticketId } = context.value
let { customerId, groupId } = context.value
@ -53,7 +54,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
}
return Mention.extend({
name: PLUGIN_NAME,
name: EXTENSION_NAME,
addCommands: () => ({
openTextMention:
() =>

View file

@ -20,8 +20,9 @@ import type { FieldEditorProps, MentionUserItem } from '../types.ts'
import type { CommandProps, MarkConfig, ParentConfig } from '@tiptap/core'
import type { Ref } from 'vue'
export const PLUGIN_NAME = 'mentionUser'
export const PLUGIN_LINK_NAME = 'mentionUserLink'
export const EXTENSION_NAME = 'mentionUser'
export const EXTENSION_LINK_NAME = 'mentionUserLink'
const ACTIVATOR = '@@'
export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
@ -44,7 +45,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
}
return Mention.extend({
name: PLUGIN_NAME,
name: EXTENSION_NAME,
addCommands: () => ({
openUserMention:
() =>
@ -74,7 +75,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
text,
marks: [
{
type: PLUGIN_LINK_NAME,
type: EXTENSION_LINK_NAME,
attrs: {
href,
target: null,
@ -91,7 +92,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
let { groupId: group } = context.value
if (!group) {
const { meta, formId } = context.value
const groupNodeName = meta?.[PLUGIN_NAME]?.groupNodeName
const groupNodeName = meta?.[EXTENSION_NAME]?.groupNodeName
if (groupNodeName) {
const groupNode = getNodeByName(formId, groupNodeName)
group = groupNode?.value as string
@ -116,7 +117,7 @@ export default (context: Ref<FormFieldContext<FieldEditorProps>>) => {
}
export const UserLink = Link.extend({
name: PLUGIN_LINK_NAME,
name: EXTENSION_LINK_NAME,
addAttributes() {
return {
// TODO: Check if this explicit typing is still needed after the release of TipTap version. > ^3.3

View file

@ -7,7 +7,7 @@ import {
useNotifications,
NotificationTypes,
} from '#shared/components/CommonNotifications/index.ts'
import { PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { getAiAssistantTextToolsClasses } from '#shared/components/Form/fields/FieldEditor/features/ai-assistant-text-tools/AiAssistantTextTools/initializeAiAssistantTextToolsClasses.ts'
import type { FieldEditorProps } from '#shared/components/Form/fields/FieldEditor/types.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
@ -30,7 +30,7 @@ const emit = defineEmits<{
}>()
const meta = props.formContext?.meta || {}
const fieldName = meta[PLUGIN_NAME]?.groupNodeName
const fieldName = meta[EXTENSION_NAME]?.groupNodeName
const { formId } = props.formContext!
const groupField = getNodeByName(formId as string, fieldName as string) as

View file

@ -4,7 +4,7 @@
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
import { getEditorEditorLinkFormClasses } from '#shared/components/Form/fields/FieldEditor/features/link/initializeLinkFormClasses.ts'
import { PLUGIN_NAME as LINK_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
import { EXTENSION_NAME as LINK_EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
import { getSelection } from '#shared/components/Form/fields/FieldEditor/utils.ts'
import Form from '#shared/components/Form/Form.vue'
import { useForm } from '#shared/components/Form/useForm.ts'
@ -50,7 +50,7 @@ const getCurrentLinkLabel = () => {
return state.doc.textBetween(from, to, '')
}
const getCurrentUrl = () => props.editor?.getAttributes(LINK_PLUGIN_NAME)?.href
const getCurrentUrl = () => props.editor?.getAttributes(LINK_EXTENSION_NAME)?.href
const hasActiveLinkMark = computed(getCurrentUrl)
@ -68,7 +68,7 @@ const handleNewLink = () => {
text: linkText.value?.length ? linkText.value : url.value,
marks: [
{
type: LINK_PLUGIN_NAME,
type: LINK_EXTENSION_NAME,
attrs: {
href: url.value,
target: '_blank',
@ -83,13 +83,13 @@ const handleLinkUpdate = () => {
props
.editor!.chain()
.focus()
.extendMarkRange(LINK_PLUGIN_NAME)
.extendMarkRange(LINK_EXTENSION_NAME)
.insertContent({
type: 'text',
text: linkText.value?.length ? linkText.value : url.value,
marks: [
{
type: LINK_PLUGIN_NAME,
type: LINK_EXTENSION_NAME,
attrs: {
href: url.value,
target: '_blank',
@ -113,7 +113,7 @@ const submitLink = () => {
}
const removeLink = () => {
props.editor!.chain().focus().unsetMark(LINK_PLUGIN_NAME, { extendEmptyMarkRange: true }).run()
props.editor!.chain().focus().unsetMark(LINK_EXTENSION_NAME, { extendEmptyMarkRange: true }).run()
close()
}

View file

@ -6,7 +6,7 @@ import { nextTick } from 'vue'
import renderComponent from '#tests/support/components/renderComponent.ts'
import LinkForm from '#shared/components/Form/fields/FieldEditor/features/link/LinkForm.vue'
import { PLUGIN_NAME as LINK_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
import { EXTENSION_NAME as LINK_EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/features/link/types.ts'
describe('LinkForm', () => {
let editor: any
@ -77,7 +77,7 @@ describe('LinkForm', () => {
text: 'Example Link',
marks: [
{
type: LINK_PLUGIN_NAME,
type: LINK_EXTENSION_NAME,
attrs: {
href: 'https://example.com',
target: '_blank',
@ -112,13 +112,13 @@ describe('LinkForm', () => {
// Verify expected editor commands were called
expect(editor.chain).toHaveBeenCalled()
expect(editor.extendMarkRange).toHaveBeenCalledWith(LINK_PLUGIN_NAME)
expect(editor.extendMarkRange).toHaveBeenCalledWith(LINK_EXTENSION_NAME)
expect(editor.insertContent).toHaveBeenCalledWith({
type: 'text',
text: 'Updated Link',
marks: [
{
type: LINK_PLUGIN_NAME,
type: LINK_EXTENSION_NAME,
attrs: {
href: 'https://updated.com',
target: '_blank',
@ -141,7 +141,9 @@ describe('LinkForm', () => {
await wrapper.events.click(removeButton)
expect(editor.chain).toHaveBeenCalled()
expect(editor.unsetMark).toHaveBeenCalledWith(LINK_PLUGIN_NAME, { extendEmptyMarkRange: true })
expect(editor.unsetMark).toHaveBeenCalledWith(LINK_EXTENSION_NAME, {
extendEmptyMarkRange: true,
})
expect(editor.commands.closeLinkForm).toHaveBeenCalled()
})

View file

@ -1,4 +1,4 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
// We can't export it from a link extension since it causes a dependency circle.
export const PLUGIN_NAME = 'link'
export const EXTENSION_NAME = 'link'

View file

@ -6,9 +6,38 @@ import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
import FieldEditorWrapper from './FieldEditorWrapper.vue'
import type { EditorExtensionSet, FieldEditorProps } from './types.ts'
import type { FormKitInputs } from '@formkit/inputs'
declare module '@formkit/inputs' {
// oxlint-disable eslint(no-unused-vars)
interface FormKitInputProps<Props extends FormKitInputs<Props>> {
editor: FieldEditorProps & {
type: 'editor'
reset?: () => void
inline?: boolean
extensionSet?: EditorExtensionSet
}
}
interface FormKitInputSlots<Props extends FormKitInputs<Props>> {
editor: FormKitBaseSlots<Props>
}
}
const fieldDefinition = createInput(
FieldEditorWrapper,
['groupId', 'ticketId', 'customerId', 'organizationId', 'meta', 'contentType'],
[
'groupId',
'ticketId',
'customerId',
'organizationId',
'meta',
'contentType',
'inline',
'extensionSet',
'reset',
],
{
features: [formUpdaterTrigger('delayed', 500), defaultEmptyValueString],
},

View file

@ -1,6 +1,6 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { PLUGIN_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME as TEXT_TOOL_EXTENSION_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import type {
KnowledgeBaseAnswerSuggestionsQuery,
MentionSuggestionsQuery,
@ -10,6 +10,7 @@ import type { ConfigList } from '#shared/types/config.ts'
import type { ConfidentTake } from '#shared/types/utils.ts'
import type { ImageFileData } from '#shared/utils/files.ts'
import type { FormKitEvent } from '@formkit/core'
import type { Except } from 'type-fest'
import type { Component } from 'vue'
@ -72,10 +73,16 @@ export interface FieldEditorContext {
getEditorValue(type: EditorContentType): string
}
export type EditorExtensionSet = 'basic'
export interface FieldEditorProps {
placeholder?: string
groupId?: string
ticketId?: string
customerId?: string
inline?: boolean
extensionSet?: EditorExtensionSet
reset?: () => void
organizationId?: string
/**
* @default 'text/html'
@ -111,7 +118,7 @@ export interface FieldEditorProps {
// where to get groupId for user mention query
groupNodeName?: string
}
[TEXT_TOOL_PLUGIN_NAME]?: {
[TEXT_TOOL_EXTENSION_NAME]?: {
disabled?: boolean
// where to get id for the current customer
customerNodeName?: string
@ -123,7 +130,7 @@ export interface FieldEditorProps {
}
}
export type EditorCustomPlugins = keyof ConfidentTake<FieldEditorProps, 'meta'>
export type EditorCustomExtensions = keyof ConfidentTake<FieldEditorProps, 'meta'>
declare module '@tiptap/vue-3' {
interface EditorEvents {
@ -169,3 +176,12 @@ export interface EditorButton {
export interface SetFloatingPopoverOptions {
onClose?: () => void
}
export interface EditChangePayload {
submitToStopEditing: (waitForCallback: () => Promise<boolean>) => void
}
export type EditorChangeCallback = (event: FormKitEvent) => void
export interface EditorChangeEvent extends FormKitEvent {
payload: EditChangePayload
}

View file

@ -0,0 +1,115 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { onClickOutside } from '@vueuse/core'
import { computed, ref, watch, type Ref, type ShallowRef } from 'vue'
import { useAppName } from '#shared/composables/useAppName.ts'
import { KeyboardKey } from '#shared/composables/useKeyboardEventBus/types.ts'
import { useKeyboardEventBus } from '#shared/composables/useKeyboardEventBus/useKeyboardEventBus.ts'
import type { FieldEditorProps } from './types'
import type { FormFieldContext } from '../../types/field'
export const useInlineMode = (
context: Ref<FormFieldContext<FieldEditorProps>>,
wrapperElement: ShallowRef<HTMLElement | null>,
) => {
const appName = useAppName()
const isEditing = ref(false)
const isInlineMode = computed(() => context.value.inline)
const onWrapperClick = () => {
if (isEditing.value || !isInlineMode.value) return
isEditing.value = true
}
const stopEditing = () => {
isEditing.value = false
}
const submitToStopEditing = (waitForCallback: () => Promise<boolean>) => {
waitForCallback().then((shouldStopEditing) => {
if (!shouldStopEditing) return
stopEditing()
isSubmitting.value = false
})
}
const handleCancel = () => {
stopEditing()
context.value?.reset?.()
// :TODO editor sometimes keeps focus after canceling, we should find a better way to handle this
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
}
const isSubmitting = ref(false)
const handleChange = () => {
isSubmitting.value = true
context.value.node.emit('change', { submitToStopEditing })
}
const handlerConfig = {
key: context.value.id,
handler: handleCancel,
}
const { subscribeEvent, unsubscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
handlerConfig,
)
const { stop } = watch(isEditing, () => {
if (isEditing.value) {
subscribeEvent(handlerConfig)
} else {
unsubscribeEvent(handlerConfig)
}
})
if (!isInlineMode.value) stop()
onClickOutside(wrapperElement, () => {
if (!isInlineMode.value) return
handleChange()
})
const wrapperInlineDesktopClasses = computed(() => {
return appName === 'desktop'
? {
'rounded-b-lg pt-0!': isInlineMode.value,
'focus-within:outline-1 focus:outline-none focus-within:-outline-offset-1 rounded-b-lg focus-within:outline-blue-800 hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 focus-within:hover:outline-blue-800 focus-visible:outline-1 dark:bg-gray-700 dark:hover:outline-blue-900 dark:focus-within:hover:outline-blue-800':
!isInlineMode.value,
'group-hover:bg-blue-200 dark:group-hover:bg-gray-700 rounded-b-lg group-focus-within:outline-1 group-focus-within:outline-blue-800 group-hover:outline-1 group-hover:-outline-offset-1 group-hover:outline-blue-600 group-focus-visible:outline-1 dark:group-hover:outline-blue-900':
isInlineMode.value && !isEditing.value,
'bg-blue-200 focus-within:outline-1 focus:outline-none focus-within:-outline-offset-1 rounded-b-lg focus-within:outline-blue-800 hover:outline-1 hover:-outline-offset-1 hover:outline-blue-600 focus-within:hover:outline-blue-800 focus-visible:outline-1 dark:bg-gray-700 dark:hover:outline-blue-900 dark:focus-within:hover:outline-blue-800':
isInlineMode.value && isEditing.value,
}
: {}
})
const containerInlineDesktopClasses = computed(() => {
return appName === 'desktop'
? {
'-mx-1 -translate-y-2 -mb-3': isInlineMode.value,
'rounded-b-lg dark:bg-gray-700': isEditing.value && isInlineMode.value,
}
: {}
})
return {
isEditing,
isSubmitting,
isInlineMode,
onWrapperClick,
handleCancel,
handleChange,
containerInlineDesktopClasses,
wrapperInlineDesktopClasses,
}
}

View file

@ -15,6 +15,7 @@ let editorClasses: FieldEditorClass = {
},
input: {
container: '',
inlineContainer: '',
},
}

View file

@ -49,7 +49,7 @@ export enum FormValidationVisibility {
Submit = 'submit',
}
export type AllowedClasses = string | Record<string, boolean> | FormKitClasses
export type AllowedClasses = string | Record<string, boolean | string> | FormKitClasses
export interface FormSchemaField {
if?: string
@ -310,6 +310,7 @@ export type FieldEditorClass = {
}
input: {
container: string
inlineContainer: string
}
}

View file

@ -11,9 +11,10 @@ import { isEmpty } from './utils.ts'
import type { OutputMode } from './types.ts'
interface Props {
mode?: OutputMode
object: ObjectLike
attribute: ObjectAttribute
mode?: OutputMode
inlineEditable?: string[]
}
const props = withDefaults(defineProps<Props>(), {

View file

@ -3,20 +3,23 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { isInlineAttributeEditable } from '#shared/components/ObjectAttributes/utils.ts'
import { useSharedVisualConfig } from '#shared/composables/useSharedVisualConfig.ts'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import { useDisplayObjectAttributes } from './useDisplayObjectAttributes.ts'
import { useInlineEditable } from './useInlineEditable.ts'
import type { OutputMode } from './types.ts'
import type { InlineEditable, OutputMode } from './types.ts'
export interface Props {
mode?: OutputMode
object: ObjectLike
attributes: ObjectAttribute[]
skipAttributes?: string[]
inlineEditable?: InlineEditable
includeStatic?: boolean
alwaysShowAfterFields?: boolean
}
@ -30,8 +33,17 @@ const { objectAttributes: objectAttributesConfig } = useSharedVisualConfig()
const { config } = storeToRefs(useApplicationStore())
const getDisplayLabel = (attribute: ObjectAttribute) =>
const getLabel = (attribute: ObjectAttribute) =>
attribute.displayConfig ? config.value[attribute.displayConfig] : attribute.display
const getDisplayLabel = (attribute: ObjectAttribute) => {
// If inline editable by default it shows then the field label
if (isInlineAttributeEditable(attribute.name, props.inlineEditable)) return null
return getLabel(attribute)
}
useInlineEditable(props, fields)
</script>
<template>
@ -49,6 +61,7 @@ const getDisplayLabel = (attribute: ObjectAttribute) =>
:value="field.value"
:config="objectAttributesConfig"
:mode="mode"
:inline-editable="inlineEditable"
/>
</CommonLink>
<Component
@ -58,6 +71,7 @@ const getDisplayLabel = (attribute: ObjectAttribute) =>
:value="field.value"
:config="objectAttributesConfig"
:mode="mode"
:inline-editable="inlineEditable"
/>
</Component>
</template>

View file

@ -103,6 +103,7 @@ describe('common object attributes interface', () => {
},
router: true,
store: true,
form: true,
})
const getRegion = (name: string) => view.getByRole('region', { name })
@ -359,4 +360,153 @@ describe('common object attributes interface', () => {
'https://url.com',
)
})
test('renders editable attributes with inline editing', async () => {
mockPermissions(['ticket.agent'])
const object = {
internalId: 123,
note: 'original note text',
objectAttributeValues: [],
}
const view = renderComponent(ObjectAttributes, {
props: {
object,
attributes: [attributesByKey.note],
inlineEditable: { note: vi.fn() },
},
router: true,
form: true,
store: true,
})
// Should render the FormKit cmp when inline editable -> vitest -> textarea
const editor = await view.findByRole('textbox')
expect(editor).toBeInTheDocument()
expect(view.queryByRole('region', { name: 'Note' })).not.toBeInTheDocument()
})
test('renders editable attributes in view mode when not inline editable', () => {
mockPermissions(['ticket.agent'])
const object = {
internalId: 123,
note: '<p>formatted note text</p>',
objectAttributeValues: [],
}
const view = renderComponent(ObjectAttributes, {
props: {
object,
attributes: [attributesByKey.note],
},
router: true,
store: true,
form: true,
})
const noteRegion = view.getByRole('region', { name: 'Note' })
expect(noteRegion).toBeInTheDocument()
expect(noteRegion).toHaveTextContent('formatted note text')
expect(view.queryByRole('textbox')).not.toBeInTheDocument()
})
test('does not render editable field if mode is not view', () => {
mockPermissions(['ticket.agent'])
const object = {
internalId: 123,
note: '<p>formatted note text</p>',
objectAttributeValues: [],
}
const view = renderComponent(ObjectAttributes, {
props: {
object,
attributes: [attributesByKey.note],
mode: 'table',
},
router: true,
store: true,
form: true,
})
const noteRegion = view.getByRole('region', { name: 'Note' })
expect(noteRegion).toBeInTheDocument()
expect(noteRegion).toHaveTextContent('formatted note text')
expect(view.queryByRole('textbox')).not.toBeInTheDocument()
})
test('does not render empty inline editable fields', async () => {
const object = {
internalId: 123,
note: '',
objectAttributeValues: [],
}
const view = renderComponent(ObjectAttributes, {
props: {
object,
attributes: [attributesByKey.note],
inlineEditable: { note: vi.fn() },
},
router: true,
form: true,
formField: true,
store: true,
})
// Empty inline editable fields should still be rendered (unlike non-editable fields)
const editor = await view.findByLabelText('Note')
expect(editor).toBeInTheDocument()
})
test.todo('calls update function when inline editable field changes', async () => {
mockPermissions(['ticket.agent'])
const object = {
internalId: 123,
note: 'original text',
objectAttributeValues: [],
}
const updateMapMock = vi.fn()
const view = renderComponent(ObjectAttributes, {
props: {
object,
attributes: [attributesByKey.note],
inlineEditable: ['note'],
updateMap: {
inlineEditable: { note: updateMapMock },
},
},
router: true,
form: true,
store: true,
})
const editor = await view.findByRole('textarea')
await view.events.type(editor, 'Update text')
// :TODO can't be tested since formKit event will not be called in the test env
// The update function should be called when the field changes
// expect(updateMapMock).toHaveBeenCalled()
// expect(updateMapMock).toHaveBeenCalledWith(
// expect.objectContaining({
// objectEntity: object,
// event: expect.any(Object),
// }),
// )
})
})

View file

@ -1,13 +1,45 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { isInlineAttributeEditable } from '#shared/components/ObjectAttributes/utils.ts'
import type { ObjectAttributeRichtext } from './attributeRichtextTypes.ts'
import type { ObjectAttributeProps } from '../../types.ts'
defineProps<ObjectAttributeProps<ObjectAttributeRichtext, string>>()
const props = defineProps<ObjectAttributeProps<ObjectAttributeRichtext, string>>()
const modelValue = ref(props.value)
const handleReset = () => {
modelValue.value = props.value
}
const enableInlineEdit = computed(
() =>
props.mode === 'view' && isInlineAttributeEditable(props.attribute.name, props.inlineEditable),
)
</script>
<template>
<FormKit
v-if="enableInlineEdit"
:id="attribute.id"
v-model="modelValue"
:name="attribute.display"
:classes="{
outer: 'w-full',
inner: 'dark:bg-transparent bg-transparent outline-0!',
input: 'min-h-7!',
}"
type="editor"
:label-sr-only="true"
:label="attribute.display"
:reset="handleReset"
:inline="true"
extension-set="basic"
/>
<!-- eslint-disable vue/no-v-html -->
<div v-html="value" />
<div v-else v-html="value" />
</template>

View file

@ -0,0 +1,11 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { Component } from 'vue'
let buttonGroup: Component | null = null
export const initButtonGroup = (cmp: Component) => {
buttonGroup = cmp
}
export const getButtonGroup = () => buttonGroup

View file

@ -1,5 +1,7 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { OperationMutationFunction } from '#shared/types/server/apollo/handler.ts'
import type { Component } from 'vue'
export type OutputMode = 'table' | 'view'
@ -17,9 +19,12 @@ export interface ObjectAttributesConfig {
}
}
export type InlineEditable = Record<string, OperationMutationFunction>
export interface ObjectAttributeProps<T, V> {
attribute: T
value: V
mode: OutputMode
config?: ObjectAttributesConfig
inlineEditable?: InlineEditable
}

View file

@ -7,9 +7,9 @@ import type { ObjectAttribute } from '#shared/entities/object-attributes/types/s
import type { ObjectAttributeValue } from '#shared/graphql/types.ts'
import type { ObjectLike } from '#shared/types/utils.ts'
import { getLink, getValue, isEmpty } from './utils.ts'
import { getLink, getValue, isEmpty, isInlineAttributeEditable } from './utils.ts'
import type { AttributeDeclaration } from './types.ts'
import type { AttributeDeclaration, InlineEditable } from './types.ts'
import type { Dictionary } from 'ts-essentials'
import type { Component } from 'vue'
@ -22,8 +22,9 @@ export interface ObjectAttributeDisplayOptions extends BaseObjectAttributeDispla
}
export interface ObjectAttributesDisplayOptions extends BaseObjectAttributeDisplayOptions {
skipAttributes?: string[]
attributes: ObjectAttribute[]
skipAttributes?: string[]
inlineEditable?: InlineEditable
includeStatic?: boolean
}
@ -76,7 +77,10 @@ export const useDisplayObjectAttributes = (options: ObjectAttributesDisplayOptio
return options.attributes
.filter((attribute) => options.includeStatic || !attribute.isStatic)
.map((attribute) => ({
attribute,
attribute: {
...attribute,
id: `${attribute.name}-${options.object.internalId}`,
},
component: definitionsByType[attribute.dataType],
value: getValue(attribute.name, options.object, attributesObject.value, attribute),
link: getLink(attribute.name, attributesObject.value),
@ -84,7 +88,7 @@ export const useDisplayObjectAttributes = (options: ObjectAttributesDisplayOptio
.filter(({ attribute, value, component }) => {
if (!component) return false
if (isEmpty(value)) {
if (isEmpty(value) && !isInlineAttributeEditable(attribute.name, options.inlineEditable)) {
return false
}

View file

@ -0,0 +1,65 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { getNode } from '@formkit/core'
import { type ComputedRef } from 'vue'
import { computed, nextTick, onMounted } from 'vue'
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
import { stripDataId } from './utils.ts'
import type { Props } from './ObjectAttributes.vue'
import type { AttributeField } from './useDisplayObjectAttributes'
export const useInlineEditable = (props: Props, fields: ComputedRef<AttributeField[]>) => {
const inlineEditObjectAttributes = computed(() =>
fields.value.filter(
({ attribute }) => props.inlineEditable && attribute.name in props.inlineEditable,
),
)
onMounted(() => {
nextTick(() => {
inlineEditObjectAttributes.value.forEach(({ attribute, value }) => {
if (!attribute?.id) return
getNode(attribute.id)?.on('change', (event) => {
const mutationFn = props.inlineEditable?.[attribute.name]
const updatedValue = stripDataId(event.origin.value as string)
const initialValue = value as string
// Currently, we pass initial value without data-id attribute
// TipTap adds it automatically due to the extension for internal reasons
// So we need to strip it before comparison
// ⚠️ Trimming won't work here as the innerHTML contain the indentation
if (updatedValue === initialValue)
return event.payload.submitToStopEditing(() => Promise.resolve(true))
if (!mutationFn) {
if (import.meta.env.DEV)
console.warn(
'No mutation call found for attribute:',
attribute.name,
`Object: ${props.object.id}`,
)
return
}
new MutationHandler(mutationFn({}))
.send({
id: props.object.id,
note: event.origin.value as string,
})
.then(() => {
event.payload.submitToStopEditing(() => Promise.resolve(true))
// Update the value in memory otherwise the next call if the value stays the same trigger the update mutation again
value = updatedValue
})
.catch(() => event.payload.submitToStopEditing(() => Promise.resolve(false)))
})
})
})
})
}

View file

@ -1,5 +1,6 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { InlineEditable } from '#shared/components/ObjectAttributes/types.ts'
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
import { useEntity } from '#shared/entities/useEntity.ts'
import type { ObjectAttributeValue } from '#shared/graphql/types.ts'
@ -69,3 +70,10 @@ export const translateOption = (attribute: ObjectAttribute, str?: string) => {
}
return str
}
export const isInlineAttributeEditable = (
attributeName: keyof InlineEditable,
inlineEditable?: InlineEditable,
) => inlineEditable && attributeName in inlineEditable
export const stripDataId = (html: string) => html.replace(/\sdata-id="[^"]*"/g, '')

View file

@ -0,0 +1,45 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
// import { useReactivate } from '#desktop/composables/useReactivate.ts'
import { onMounted, ref } from 'vue'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import { useReactivate } from '#shared/composables/useReactivate.ts'
describe('useReactivate', () => {
it('should call callbacks appropriate', () => {
renderComponent({
template: `
<KeepAlive>
<div v-if="show"/>
</KeepAlive>`,
setup() {
const onActivatedCallback = vi.fn()
const onDeactivatedCallback = vi.fn()
const show = ref(true)
useReactivate(onActivatedCallback, onDeactivatedCallback)
onMounted(() => {
// Initial mounting component should not call the callback
expect(onActivatedCallback).not.toHaveBeenCalled()
setTimeout(async () => {
show.value = false
await waitForNextTick()
expect(onDeactivatedCallback).toHaveBeenCalled()
show.value = true
await waitForNextTick()
expect(onActivatedCallback).toHaveBeenCalled()
}, 50)
})
return { show }
},
})
})
})

View file

@ -2,8 +2,8 @@
import renderComponent from '#tests/support/components/renderComponent.ts'
import { KeyboardKey } from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
import { KeyboardKey } from '#shared/composables/useKeyboardEventBus/types.ts'
import { useKeyboardEventBus } from '#shared/composables/useKeyboardEventBus/useKeyboardEventBus.ts'
const mountComponent = (setup: () => object | void) =>
renderComponent({

View file

@ -7,8 +7,9 @@ import { effectScope, onBeforeUnmount, onDeactivated, shallowRef, watch } from '
import {
type OrderKeyHandlerConfig,
KeyboardKey,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useReactivate } from '#desktop/composables/useReactivate.ts'
} from '#shared/composables/useKeyboardEventBus/types.ts'
import { useReactivate } from '../useReactivate.ts'
const subscribedHandlers = shallowRef<Record<string, OrderKeyHandlerConfig[]>>({})

View file

@ -24,7 +24,9 @@ describe('FieldResolverRichtext', () => {
label: 'Body',
name: 'body',
required: false,
props: {},
props: {
extensionSet: 'basic',
},
type: 'editor',
internal: true,
})

View file

@ -17,7 +17,9 @@ export class FieldResolverRichtext extends FieldResolver {
// end
public fieldTypeAttributes() {
return {
props: {},
props: {
extensionSet: 'basic',
},
}
}
}

View file

@ -10,6 +10,7 @@ import type { JsonValue } from 'type-fest'
import type { ComputedRef, Ref } from 'vue'
export interface ObjectAttribute extends ObjectManagerFrontendAttribute {
id?: string
isStatic?: boolean
displayConfig?: string
dataOption?: {

View file

@ -0,0 +1,24 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { ErrorsFragmentDoc } from '../../../../graphql/fragments/errors.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const OrganizationNoteUpdateDocument = gql`
mutation organizationNoteUpdate($id: ID!, $note: String!) {
organizationNoteUpdate(id: $id, note: $note) {
organization {
note
}
errors {
...errors
}
}
}
${ErrorsFragmentDoc}`;
export function useOrganizationNoteUpdateMutation(options: VueApolloComposable.UseMutationOptions<Types.OrganizationNoteUpdateMutation, Types.OrganizationNoteUpdateMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.OrganizationNoteUpdateMutation, Types.OrganizationNoteUpdateMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.OrganizationNoteUpdateMutation, Types.OrganizationNoteUpdateMutationVariables>(OrganizationNoteUpdateDocument, options);
}
export type OrganizationNoteUpdateMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.OrganizationNoteUpdateMutation, Types.OrganizationNoteUpdateMutationVariables>;

View file

@ -0,0 +1,10 @@
mutation organizationNoteUpdate($id: ID!, $note: String!) {
organizationNoteUpdate(id: $id, note: $note) {
organization {
note
}
errors {
...errors
}
}
}

View file

@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './noteUpdate.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockOrganizationNoteUpdateMutation(defaults: Mocks.MockDefaultsValue<Types.OrganizationNoteUpdateMutation, Types.OrganizationNoteUpdateMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.OrganizationNoteUpdateDocument, defaults)
}
export function waitForOrganizationNoteUpdateMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.OrganizationNoteUpdateMutation>(Operations.OrganizationNoteUpdateDocument)
}
export function mockOrganizationNoteUpdateMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.OrganizationNoteUpdateDocument, message, extensions);
}

View file

@ -3,7 +3,7 @@
import { keyBy } from 'lodash-es'
import { computed, shallowRef } from 'vue'
import { PLUGIN_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import { EXTENSION_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import type { FieldEditorContext } from '#shared/components/Form/fields/FieldEditor/types.ts'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import type {

View file

@ -8,8 +8,8 @@ import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const TicketTitleUpdateDocument = gql`
mutation ticketTitleUpdate($ticketId: ID!, $input: TicketTitleUpdateInput!) {
ticketTitleUpdate(ticketId: $ticketId, input: $input) {
mutation ticketTitleUpdate($ticketId: ID!, $title: String!) {
ticketTitleUpdate(ticketId: $ticketId, title: $title) {
ticket {
...ticketAttributes
}

View file

@ -1,5 +1,5 @@
mutation ticketTitleUpdate($ticketId: ID!, $input: TicketTitleUpdateInput!) {
ticketTitleUpdate(ticketId: $ticketId, input: $input) {
mutation ticketTitleUpdate($ticketId: ID!, $title: String!) {
ticketTitleUpdate(ticketId: $ticketId, title: $title) {
ticket {
...ticketAttributes
}

View file

@ -0,0 +1,24 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { ErrorsFragmentDoc } from '../../../../graphql/fragments/errors.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserNoteUpdateDocument = gql`
mutation userNoteUpdate($id: ID!, $note: String!) {
userNoteUpdate(id: $id, note: $note) {
user {
note
}
errors {
...errors
}
}
}
${ErrorsFragmentDoc}`;
export function useUserNoteUpdateMutation(options: VueApolloComposable.UseMutationOptions<Types.UserNoteUpdateMutation, Types.UserNoteUpdateMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.UserNoteUpdateMutation, Types.UserNoteUpdateMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.UserNoteUpdateMutation, Types.UserNoteUpdateMutationVariables>(UserNoteUpdateDocument, options);
}
export type UserNoteUpdateMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.UserNoteUpdateMutation, Types.UserNoteUpdateMutationVariables>;

View file

@ -0,0 +1,10 @@
mutation userNoteUpdate($id: ID!, $note: String!) {
userNoteUpdate(id: $id, note: $note) {
user {
note
}
errors {
...errors
}
}
}

View file

@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './noteUpdate.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockUserNoteUpdateMutation(defaults: Mocks.MockDefaultsValue<Types.UserNoteUpdateMutation, Types.UserNoteUpdateMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.UserNoteUpdateDocument, defaults)
}
export function waitForUserNoteUpdateMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.UserNoteUpdateMutation>(Operations.UserNoteUpdateDocument)
}
export function mockUserNoteUpdateMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.UserNoteUpdateDocument, message, extensions);
}

View file

@ -1,8 +1,8 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { UserAttributesFragmentDoc } from '../../../../../../shared/graphql/fragments/userAttributes.api';
import { ErrorsFragmentDoc } from '../../../../../../shared/graphql/fragments/errors.api';
import { UserAttributesFragmentDoc } from '../../../../graphql/fragments/userAttributes.api';
import { ErrorsFragmentDoc } from '../../../../graphql/fragments/errors.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;

View file

@ -15,6 +15,7 @@ export const staticObjectAttributes: EntityStaticObjectAttributes = {
dataOption: {
relation: 'User',
},
id: 'created_by_id-1',
dataType: 'autocomplete',
isStatic: true,
isInternal: true,
@ -23,6 +24,7 @@ export const staticObjectAttributes: EntityStaticObjectAttributes = {
name: 'created_at',
display: __('Created at'),
dataType: 'datetime',
id: 'created_at-1',
isStatic: true,
isInternal: true,
},
@ -33,12 +35,14 @@ export const staticObjectAttributes: EntityStaticObjectAttributes = {
relation: 'User',
},
dataType: 'autocomplete',
id: 'updated_by_id-1',
isStatic: true,
isInternal: true,
},
{
name: 'updated_at',
display: __('Updated at'),
id: 'updated_at-1',
dataType: 'datetime',
isStatic: true,
isInternal: true,

View file

@ -1669,6 +1669,8 @@ export type Mutations = {
onlineNotificationMarkAllAsSeen?: Maybe<OnlineNotificationMarkAllAsSeenPayload>;
/** Mark an online notification as seen */
onlineNotificationSeen?: Maybe<OnlineNotificationSeenPayload>;
/** Update the note field of an organization. */
organizationNoteUpdate?: Maybe<OrganizationNoteUpdatePayload>;
/** Update organization data. */
organizationUpdate?: Maybe<OrganizationUpdatePayload>;
/** Verify and apply third-party system import configuration */
@ -1743,7 +1745,7 @@ export type Mutations = {
ticketSharedDraftZoomDelete?: Maybe<TicketSharedDraftZoomDeletePayload>;
/** Update ticket shared draft in detail view */
ticketSharedDraftZoomUpdate?: Maybe<TicketSharedDraftZoomUpdatePayload>;
/** Update a ticket. */
/** Update a ticket title. */
ticketTitleUpdate?: Maybe<TicketTitleUpdatePayload>;
/** Update a ticket. */
ticketUpdate?: Maybe<TicketUpdatePayload>;
@ -1815,6 +1817,8 @@ export type Mutations = {
userCurrentTwoFactorSetDefaultMethod?: Maybe<UserCurrentTwoFactorSetDefaultMethodPayload>;
/** Verifies two factor authentication method configuration. */
userCurrentTwoFactorVerifyMethodConfiguration?: Maybe<UserCurrentTwoFactorVerifyMethodConfigurationPayload>;
/** Update the note field of a user. */
userNoteUpdate?: Maybe<UserNoteUpdatePayload>;
/** Send password reset link to the user. */
userPasswordResetSend?: Maybe<UserPasswordResetSendPayload>;
/** Update user password via reset token. */
@ -1974,6 +1978,13 @@ export type MutationsOnlineNotificationSeenArgs = {
};
/** All available mutations */
export type MutationsOrganizationNoteUpdateArgs = {
id: Scalars['ID']['input'];
note: Scalars['String']['input'];
};
/** All available mutations */
export type MutationsOrganizationUpdateArgs = {
id: Scalars['ID']['input'];
@ -2221,8 +2232,8 @@ export type MutationsTicketSharedDraftZoomUpdateArgs = {
/** All available mutations */
export type MutationsTicketTitleUpdateArgs = {
input: TicketTitleUpdateInput;
ticketId: Scalars['ID']['input'];
title: Scalars['String']['input'];
};
@ -2436,6 +2447,13 @@ export type MutationsUserCurrentTwoFactorVerifyMethodConfigurationArgs = {
};
/** All available mutations */
export type MutationsUserNoteUpdateArgs = {
id: Scalars['ID']['input'];
note: Scalars['String']['input'];
};
/** All available mutations */
export type MutationsUserPasswordResetSendArgs = {
username: Scalars['String']['input'];
@ -2712,6 +2730,15 @@ export type OrganizationInput = {
shared?: InputMaybe<Scalars['Boolean']['input']>;
};
/** Autogenerated return type of OrganizationNoteUpdate. */
export type OrganizationNoteUpdatePayload = {
__typename?: 'OrganizationNoteUpdatePayload';
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** The updated organization. */
organization?: Maybe<Organization>;
};
/** Autogenerated return type of OrganizationUpdate. */
export type OrganizationUpdatePayload = {
__typename?: 'OrganizationUpdatePayload';
@ -4781,12 +4808,6 @@ export type TicketTimeAccountingTypeSum = {
timeUnit: Scalars['Float']['output'];
};
/** Payload to update a ticket customer */
export type TicketTitleUpdateInput = {
/** The title of the ticket. */
title: Scalars['String']['input'];
};
/** Autogenerated return type of TicketTitleUpdate. */
export type TicketTitleUpdatePayload = {
__typename?: 'TicketTitleUpdatePayload';
@ -5551,6 +5572,15 @@ export type UserLoginTwoFactorMethods = {
recoveryCodesAvailable: Scalars['Boolean']['output'];
};
/** Autogenerated return type of UserNoteUpdate. */
export type UserNoteUpdatePayload = {
__typename?: 'UserNoteUpdatePayload';
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** The created user. */
user?: Maybe<User>;
};
/** Settings for ticket notification channels. */
export type UserNotificationMatrixChannelInput = {
/** Whether to send notifications via email */
@ -6825,14 +6855,6 @@ export type TicketsByOverviewSlimQueryVariables = Exact<{
export type TicketsByOverviewSlimQuery = { __typename?: 'Queries', ticketsByOverview: { __typename?: 'TicketConnection', totalCount: number, edges: Array<{ __typename?: 'TicketEdge', cursor: string, node: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, updatedAt: string, aiAgentRunning?: boolean | null, stateColorCode: EnumTicketStateColorCode, updatedBy?: { __typename?: 'User', id: string, fullname?: string | null } | null, customer: { __typename?: 'User', id: string, firstname?: string | null, lastname?: string | null, fullname?: string | null }, organization?: { __typename?: 'Organization', id: string, name?: string | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null }, priority?: { __typename?: 'TicketPriority', id: string, name: string, uiColor?: string | null, defaultCreate: boolean }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type UserUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
input: UserInput;
}>;
export type UserUpdateMutation = { __typename?: 'Mutations', userUpdate?: { __typename?: 'UserUpdatePayload', user?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, image?: string | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, preferences?: any | null, hasSecondaryOrganizations?: boolean | null, outOfOfficeReplacement?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, login?: string | null, phone?: string | null, email?: string | null } | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, personalSettings?: { __typename?: 'UserPersonalSettings', notificationConfig?: { __typename?: 'UserPersonalSettingsNotificationConfig', groupIds?: Array<number> | null, matrix?: { __typename?: 'UserPersonalSettingsNotificationMatrix', create?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, escalation?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, reminderReached?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, update?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null } | null } | null, notificationSound?: { __typename?: 'UserPersonalSettingsNotificationSound', enabled?: boolean | null, file?: EnumNotificationSoundFile | null } | null } | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type AutocompleteSearchAgentQueryVariables = Exact<{
input: AutocompleteSearchUserInput;
}>;
@ -7000,6 +7022,14 @@ export type OrganizationAttributesFragment = { __typename?: 'Organization', id:
export type OrganizationMembersFragment = { __typename?: 'Organization', allMembers?: { __typename?: 'UserConnection', totalCount: number, edges: Array<{ __typename?: 'UserEdge', node: { __typename?: 'User', id: string, internalId: number, image?: string | null, firstname?: string | null, lastname?: string | null, fullname?: string | null, email?: string | null, phone?: string | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, vip?: boolean | null } }> } | null };
export type OrganizationNoteUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
note: Scalars['String']['input'];
}>;
export type OrganizationNoteUpdateMutation = { __typename?: 'Mutations', organizationNoteUpdate?: { __typename?: 'OrganizationNoteUpdatePayload', organization?: { __typename?: 'Organization', note?: string | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type OrganizationQueryVariables = Exact<{
organizationId: Scalars['ID']['input'];
membersCount?: InputMaybe<Scalars['Int']['input']>;
@ -7223,7 +7253,7 @@ export type MentionSubscribeMutation = { __typename?: 'Mutations', mentionSubscr
export type TicketTitleUpdateMutationVariables = Exact<{
ticketId: Scalars['ID']['input'];
input: TicketTitleUpdateInput;
title: Scalars['String']['input'];
}>;
@ -7406,6 +7436,22 @@ export type UserAddMutationVariables = Exact<{
export type UserAddMutation = { __typename?: 'Mutations', userAdd?: { __typename?: 'UserAddPayload', user?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, image?: string | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, preferences?: any | null, hasSecondaryOrganizations?: boolean | null, outOfOfficeReplacement?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, login?: string | null, phone?: string | null, email?: string | null } | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, personalSettings?: { __typename?: 'UserPersonalSettings', notificationConfig?: { __typename?: 'UserPersonalSettingsNotificationConfig', groupIds?: Array<number> | null, matrix?: { __typename?: 'UserPersonalSettingsNotificationMatrix', create?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, escalation?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, reminderReached?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, update?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null } | null } | null, notificationSound?: { __typename?: 'UserPersonalSettingsNotificationSound', enabled?: boolean | null, file?: EnumNotificationSoundFile | null } | null } | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type UserNoteUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
note: Scalars['String']['input'];
}>;
export type UserNoteUpdateMutation = { __typename?: 'Mutations', userNoteUpdate?: { __typename?: 'UserNoteUpdatePayload', user?: { __typename?: 'User', note?: string | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type UserUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
input: UserInput;
}>;
export type UserUpdateMutation = { __typename?: 'Mutations', userUpdate?: { __typename?: 'UserUpdatePayload', user?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, image?: string | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, preferences?: any | null, hasSecondaryOrganizations?: boolean | null, outOfOfficeReplacement?: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, login?: string | null, phone?: string | null, email?: string | null } | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, personalSettings?: { __typename?: 'UserPersonalSettings', notificationConfig?: { __typename?: 'UserPersonalSettingsNotificationConfig', groupIds?: Array<number> | null, matrix?: { __typename?: 'UserPersonalSettingsNotificationMatrix', create?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, escalation?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, reminderReached?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, update?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null } | null } | null, notificationSound?: { __typename?: 'UserPersonalSettingsNotificationSound', enabled?: boolean | null, file?: EnumNotificationSoundFile | null } | null } | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type UserQueryVariables = Exact<{
userId: Scalars['ID']['input'];
secondaryOrganizationsCount?: InputMaybe<Scalars['Int']['input']>;

View file

@ -0,0 +1,31 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class Organization::NoteUpdate < BaseMutation
description 'Update the note field of an organization.'
argument :id, GraphQL::Types::ID, description: 'The organization ID', as: :current_organization, loads: Gql::Types::OrganizationType
argument :note, String, description: 'The organization note'
field :organization, Gql::Types::OrganizationType, description: 'The updated organization.'
# TODO/FIXME: Remove this again when we have a proper solution to deal with Pundit stuff in GraphQL mutations.
def self.authorize(_obj, ctx)
ctx.current_user.permissions?(['admin.organization', 'ticket.agent'])
end
def resolve(current_organization:, note:)
{ organization: forced_update(current_organization:, note:) }
end
private
def forced_update(current_organization:, note:)
current_organization.with_lock do
current_organization.update!({ note: })
end
current_organization
end
end
end

View file

@ -2,16 +2,16 @@
module Gql::Mutations
class Ticket::TitleUpdate < BaseMutation
description 'Update a ticket.'
description 'Update a ticket title.'
argument :ticket_id, GraphQL::Types::ID, loads: Gql::Types::TicketType, loads_pundit_method: :agent_read_access?, description: 'The ticket to be updated'
argument :input, Gql::Types::Input::Ticket::TitleUpdateInputType, description: 'The ticket update data'
argument :title, String, description: 'The title of the ticket.', required: true
field :ticket, Gql::Types::TicketType, description: 'The updated ticket.'
def resolve(ticket:, input:)
def resolve(ticket:, title:)
Service::Ticket::ForcedUpdate
.new(ticket, input.to_h)
.new(ticket, { title: })
.execute
{ ticket: }

View file

@ -0,0 +1,26 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class User::NoteUpdate < BaseMutation
description 'Update the note field of a user.'
argument :id, GraphQL::Types::ID, description: 'The user ID', as: :current_user, loads: Gql::Types::UserType, loads_pundit_method: :update?
argument :note, String, description: 'The user note'
field :user, Gql::Types::UserType, description: 'The created user.'
def resolve(current_user:, note:)
{ user: forced_update(current_user:, note:) }
end
private
def forced_update(current_user:, note:)
current_user.with_lock do
current_user.update!({ note: })
end
current_user
end
end
end

View file

@ -1,9 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types::Input::Ticket
class TitleUpdateInputType < Gql::Types::BaseInputObject
description 'Payload to update a ticket customer'
argument :title, String, description: 'The title of the ticket.', required: true
end
end

View file

@ -10462,6 +10462,47 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "organizationNoteUpdate",
"description": "Update the note field of an organization.",
"args": [
{
"name": "id",
"description": "The organization ID",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "note",
"description": "The organization note",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "OrganizationNoteUpdatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "organizationUpdate",
"description": "Update organization data.",
@ -11850,7 +11891,7 @@
},
{
"name": "ticketTitleUpdate",
"description": "Update a ticket.",
"description": "Update a ticket title.",
"args": [
{
"name": "ticketId",
@ -11867,14 +11908,14 @@
"defaultValue": null
},
{
"name": "input",
"description": "The ticket update data",
"name": "title",
"description": "The title of the ticket.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TicketTitleUpdateInput",
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
@ -13065,6 +13106,47 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userNoteUpdate",
"description": "Update the note field of a user.",
"args": [
{
"name": "id",
"description": "The user ID",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "note",
"description": "The user note",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "UserNoteUpdatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userPasswordResetSend",
"description": "Send password reset link to the user.",
@ -14695,6 +14777,49 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "OrganizationNoteUpdatePayload",
"description": "Autogenerated return type of OrganizationNoteUpdate.",
"fields": [
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserError",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "organization",
"description": "The updated organization.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "Organization",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "OrganizationUpdatePayload",
@ -26561,31 +26686,6 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "TicketTitleUpdateInput",
"description": "Payload to update a ticket customer",
"fields": null,
"inputFields": [
{
"name": "title",
"description": "The title of the ticket.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TicketTitleUpdatePayload",
@ -30859,6 +30959,49 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UserNoteUpdatePayload",
"description": "Autogenerated return type of UserNoteUpdate.",
"fields": [
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserError",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "user",
"description": "The created user.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "User",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UserNotificationMatrixChannelInput",

View file

@ -370,7 +370,7 @@ msgstr ""
msgid "%s switched to |%s|!"
msgstr ""
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:71
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:70
msgid "%s ticket selected"
msgstr ""
@ -385,7 +385,7 @@ msgstr ""
msgid "%s tickets found"
msgstr ""
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:72
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:71
msgid "%s tickets selected"
msgstr ""
@ -613,7 +613,7 @@ msgstr ""
msgid "A test ticket has been created, you can find it in your overview \"%s\" %l."
msgstr ""
#: app/models/ticket.rb:295
#: app/models/ticket.rb:296
msgid "A ticket cannot be merged into itself."
msgstr ""
@ -661,7 +661,7 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: db/seeds/settings.rb:5990
#: db/seeds/settings.rb:6027
msgid "AI Provider Config"
msgstr ""
@ -694,7 +694,7 @@ msgstr ""
msgid "AI error log"
msgstr ""
#: db/seeds/settings.rb:5976
#: db/seeds/settings.rb:6013
msgid "AI provider"
msgstr ""
@ -1409,7 +1409,7 @@ msgid "Agent idle timeout"
msgstr ""
#: app/models/role.rb:151
#: app/models/user.rb:756
#: app/models/user.rb:757
msgid "Agent limit exceeded, please check your account settings."
msgstr ""
@ -1523,7 +1523,7 @@ msgstr ""
msgid "Allow users to create new tags."
msgstr ""
#: db/seeds/settings.rb:5970
#: db/seeds/settings.rb:6007
msgid "Allow users to switch automatically to the new desktop UI."
msgstr ""
@ -1715,7 +1715,7 @@ msgid "Applications"
msgstr ""
#: app/assets/javascripts/app/views/widget/template.jst.eco:8
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:329
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:311
#: app/frontend/apps/desktop/pages/ticket/components/TicketSharedDraftFlyout.vue:133
msgid "Apply"
msgstr ""
@ -1992,7 +1992,7 @@ msgstr ""
msgid "At least one filter must be provided."
msgstr ""
#: app/models/user.rb:658
#: app/models/user.rb:659
msgid "At least one identifier (firstname, lastname, phone, mobile or email) for user is required."
msgstr ""
@ -2005,7 +2005,7 @@ msgid "At least one object must be selected."
msgstr ""
#: app/models/role.rb:128
#: app/models/user.rb:735
#: app/models/user.rb:736
msgid "At least one user needs to have admin permissions."
msgstr ""
@ -2157,7 +2157,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/authenticator_app.coffee:4
#: app/assets/javascripts/app/lib/app_post/two_factor_methods/authenticator_app.coffee:5
#: app/frontend/shared/entities/two-factor/plugins/authenticator-app.ts:9
#: db/seeds/settings.rb:5817
#: db/seeds/settings.rb:5854
msgid "Authenticator App"
msgstr ""
@ -2202,7 +2202,7 @@ msgstr ""
msgid "Auto Assignment Selector"
msgstr ""
#: db/seeds/settings.rb:5956
#: db/seeds/settings.rb:5993
msgid "Auto Shutdown"
msgstr ""
@ -2408,7 +2408,7 @@ msgid "Be sure to check AI-generated content for accuracy."
msgstr ""
#: app/assets/javascripts/app/lib/base/jquery.textmodule.js:788
#: app/frontend/shared/components/Form/fields/FieldEditor/extensions/UserMention.ts:104
#: app/frontend/shared/components/Form/fields/FieldEditor/extensions/UserMention.ts:105
msgid "Before you mention a user, please select a group."
msgstr ""
@ -2715,7 +2715,7 @@ msgstr ""
#: app/assets/javascripts/app/views/signup.jst.eco:7
#: app/frontend/apps/desktop/components/CommonDialog/CommonDialogActionFooter.vue:19
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyoutActionFooter.vue:10
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:319
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:301
#: app/frontend/apps/desktop/pages/authentication/components/LoginTwoFactorMethods.vue:53
#: app/frontend/apps/desktop/pages/authentication/views/AdminPasswordAuth.vue:116
#: app/frontend/apps/desktop/pages/authentication/views/PasswordReset.vue:111
@ -2831,7 +2831,7 @@ msgstr ""
msgid "Change Your Password"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:68
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:69
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue:82
#: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketAction/TicketActionChangeCustomerDialog.vue:67
#: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketActionsDialog.vue:129
@ -3032,7 +3032,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/checklist_template.coffee:3
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:7
#: db/seeds/permissions.rb:323
#: db/seeds/settings.rb:5929
#: db/seeds/settings.rb:5966
msgid "Checklists"
msgstr ""
@ -3209,6 +3209,10 @@ msgstr ""
msgid "Click here to set up a two-factor authentication method."
msgstr ""
#: app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorInput.vue:49
msgid "Click to edit…"
msgstr ""
#: app/assets/javascripts/app/views/exchange/app_config.jst.eco:9
#: app/assets/javascripts/app/views/exchange/token_information.jst.eco:28
#: app/assets/javascripts/app/views/integration/idoit.jst.eco:21
@ -3312,7 +3316,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/widget/ticket_stats.coffee:130
#: app/assets/javascripts/app/controllers/widget/user.coffee:80
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:127
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:129
msgid "Closed Tickets"
msgstr ""
@ -3365,7 +3369,7 @@ msgstr ""
#: app/assets/javascripts/app/lib/app_post/two_factor_methods/security_keys.coffee:6
#: app/frontend/shared/entities/two-factor/plugins/security-keys.ts:11
#: db/seeds/settings.rb:5809
#: db/seeds/settings.rb:5846
msgid "Complete the sign-in with your security key."
msgstr ""
@ -3959,7 +3963,7 @@ msgid "Create ticket"
msgstr ""
#: app/assets/javascripts/app/views/customer_not_ticket_exists.jst.eco:14
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:356
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:417
msgid "Create your first ticket"
msgstr ""
@ -4032,7 +4036,7 @@ msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue:38
#: app/frontend/shared/entities/organization/stores/objectAttributes.ts:24
#: app/frontend/shared/entities/ticket/stores/objectAttributes.ts:107
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:24
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:25
#: app/graphql/gql/types/overview_type.rb:113
msgid "Created at"
msgstr ""
@ -4631,7 +4635,7 @@ msgstr ""
msgid "Defines if Nagios (http://www.nagios.org) is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5677
#: db/seeds/settings.rb:5714
msgid "Defines if PGP encryption is enabled or not."
msgstr ""
@ -4639,7 +4643,7 @@ msgstr ""
msgid "Defines if Placetel (http://www.placetel.de) is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5635
#: db/seeds/settings.rb:5672
msgid "Defines if S/MIME encryption is enabled or not."
msgstr ""
@ -4655,7 +4659,7 @@ msgstr ""
msgid "Defines if application is used as online service."
msgstr ""
#: db/seeds/settings.rb:5760
#: db/seeds/settings.rb:5797
msgid "Defines if calendar weeks are shown in the picker of date/datetime fields to easily select the correct date."
msgstr ""
@ -4663,7 +4667,7 @@ msgstr ""
msgid "Defines if generic CTI integration is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5852
#: db/seeds/settings.rb:5889
msgid "Defines if recovery codes can be used by users in the event they lose access to other two-factor authentication methods."
msgstr ""
@ -4671,7 +4675,7 @@ msgstr ""
msgid "Defines if sipgate.io (http://www.sipgate.io) is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5979
#: db/seeds/settings.rb:6016
msgid "Defines if the AI provider is configured."
msgstr ""
@ -4683,7 +4687,7 @@ msgstr ""
msgid "Defines if the GitLab (http://www.gitlab.com) integration is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5719
#: db/seeds/settings.rb:5756
msgid "Defines if the PGP recipient alias configuration is enabled or not."
msgstr ""
@ -4695,11 +4699,11 @@ msgstr ""
msgid "Defines if the application is in developer mode (all users have the same password and password reset will work without email delivery)."
msgstr ""
#: db/seeds/settings.rb:6070
#: db/seeds/settings.rb:6107
msgid "Defines if the bubble menu feature of the richtext editor is enabled. Note that this setting will be ignored if the writing assistant is turned on."
msgstr ""
#: db/seeds/settings.rb:5904
#: db/seeds/settings.rb:5941
msgid "Defines if the change of the primary organization of a user will update the 100 most recent tickets for this user as well."
msgstr ""
@ -4727,11 +4731,11 @@ msgstr ""
msgid "Defines if the time accounting types are enabled."
msgstr ""
#: db/seeds/settings.rb:5820
#: db/seeds/settings.rb:5857
msgid "Defines if the two-factor authentication method authenticator app is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5788
#: db/seeds/settings.rb:5825
msgid "Defines if the two-factor authentication method security keys is enabled or not."
msgstr ""
@ -4883,7 +4887,7 @@ msgstr ""
msgid "Defines the HTTP protocol of your instance."
msgstr ""
#: db/seeds/settings.rb:5705
#: db/seeds/settings.rb:5742
msgid "Defines the PGP config."
msgstr ""
@ -4891,7 +4895,7 @@ msgstr ""
msgid "Defines the Placetel config."
msgstr ""
#: db/seeds/settings.rb:5663
#: db/seeds/settings.rb:5700
msgid "Defines the S/MIME config."
msgstr ""
@ -4959,7 +4963,7 @@ msgstr ""
msgid "Defines the duration of customer activity (in seconds) on a call until the user profile dialog is shown."
msgstr ""
#: db/seeds/settings.rb:6056
#: db/seeds/settings.rb:6093
msgid "Defines the fixed instructions that guide the AI Writing Assistant on e.g. how to format its output."
msgstr ""
@ -5581,7 +5585,7 @@ msgstr ""
msgid "Do not sign email"
msgstr ""
#: db/seeds/settings.rb:5914
#: db/seeds/settings.rb:5951
msgid "Do not update any tickets."
msgstr ""
@ -5837,7 +5841,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee:21
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee:11
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarOrganization/TicketSidebarOrganizationContent.vue:35
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarOrganization/TicketSidebarOrganizationContent.vue:36
#: app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationOrganization.vue:76
msgid "Edit Organization"
msgstr ""
@ -6038,7 +6042,7 @@ msgstr ""
msgid "Email address"
msgstr ""
#: app/models/user.rb:668
#: app/models/user.rb:669
msgid "Email address '%{email}' is already used for another user."
msgstr ""
@ -6082,7 +6086,7 @@ msgstr ""
msgid "Empty"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:362
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:423
msgid "Empty Overview"
msgstr ""
@ -6115,7 +6119,7 @@ msgstr ""
msgid "Enable REST API using tokens (not username/email address and password). Each user needs to create its own access tokens in user profile."
msgstr ""
#: db/seeds/settings.rb:5849
#: db/seeds/settings.rb:5886
msgid "Enable Recovery Codes"
msgstr ""
@ -6135,7 +6139,7 @@ msgstr ""
msgid "Enable automatic assignment the first time an agent opens a ticket."
msgstr ""
#: db/seeds/settings.rb:5932
#: db/seeds/settings.rb:5969
msgid "Enable checklists."
msgstr ""
@ -6151,7 +6155,7 @@ msgstr ""
msgid "Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available)."
msgstr ""
#: db/seeds/settings.rb:5959
#: db/seeds/settings.rb:5996
msgid "Enable or disable self-shutdown of Zammad processes after significant configuration changes. This should only be used if the controlling process manager like systemd or docker supports an automatic restart policy."
msgstr ""
@ -6163,11 +6167,11 @@ msgstr ""
msgid "Enable or disable the maintenance mode of Zammad. If enabled, all non-administrators get logged out and only administrators can start a new session."
msgstr ""
#: db/seeds/settings.rb:6009
#: db/seeds/settings.rb:6046
msgid "Enable or disable the ticket summary."
msgstr ""
#: db/seeds/settings.rb:6042
#: db/seeds/settings.rb:6079
msgid "Enable or disable the writing assistant text tools."
msgstr ""
@ -6204,7 +6208,7 @@ msgstr ""
msgid "Enables a warning to users during ticket creation if there is an existing ticket with the same attributes."
msgstr ""
#: db/seeds/settings.rb:5730
#: db/seeds/settings.rb:5767
msgid "Enables button for user authentication via %s. The button will redirect to /auth/sso on user interaction."
msgstr ""
@ -6329,11 +6333,11 @@ msgstr ""
msgid "Endpoint Settings"
msgstr ""
#: db/seeds/settings.rb:5876
#: db/seeds/settings.rb:5913
msgid "Enforce the setup of the two-factor authentication"
msgstr ""
#: db/seeds/settings.rb:5883
#: db/seeds/settings.rb:5920
msgid "Enforced for user roles"
msgstr ""
@ -7326,7 +7330,7 @@ msgstr ""
#: app/assets/javascripts/app/lib/app_post/two_factor_methods/authenticator_app.coffee:6
#: app/frontend/shared/entities/two-factor/plugins/authenticator-app.ts:10
#: db/seeds/settings.rb:5841
#: db/seeds/settings.rb:5878
msgid "Get the security code from the authenticator app on your device."
msgstr ""
@ -7668,7 +7672,7 @@ msgstr ""
msgid "Has processed"
msgstr ""
#: app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue:154
#: app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue:156
#: app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInputDropdownItem.vue:149
#: app/frontend/apps/mobile/components/Form/fields/FieldTreeSelect/FieldTreeSelectInputDialog.vue:309
msgid "Has submenu"
@ -8484,7 +8488,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee:105
#: app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee:133
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:161
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:143
#: app/frontend/shared/entities/ticket/composables/useTicketEditForm.ts:152
msgid "Internal"
msgstr ""
@ -8555,7 +8559,7 @@ msgstr ""
msgid "Invalid client_id received!"
msgstr ""
#: app/models/user.rb:607
#: app/models/user.rb:608
msgid "Invalid email '%{email}'"
msgstr ""
@ -8719,7 +8723,7 @@ msgstr ""
msgid "It is not possible to delete your current account."
msgstr ""
#: app/models/ticket.rb:293
#: app/models/ticket.rb:294
msgid "It is not possible to merge into an already merged ticket."
msgstr ""
@ -9371,7 +9375,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/macro.coffee:3
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco:78
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:212
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:194
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailBottomBar/TicketAgentUpdateButton.vue:46
#: db/seeds/permissions.rb:47
msgid "Macros"
@ -9858,7 +9862,7 @@ msgstr ""
#: app/assets/javascripts/app/views/popover/organization.jst.eco:6
#: app/assets/javascripts/app/views/widget/organization.jst.eco:29
#: app/frontend/apps/desktop/components/Organization/OrganizationPopoverWithTrigger/OrganizationPopover.vue:81
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarOrganization/TicketSidebarOrganizationContent.vue:65
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarOrganization/TicketSidebarOrganizationContent.vue:69
#: app/frontend/apps/mobile/components/Organization/OrganizationMembersList.vue:29
msgid "Members"
msgstr ""
@ -10127,7 +10131,7 @@ msgstr ""
msgid "More information can be found here."
msgstr ""
#: app/models/user.rb:687
#: app/models/user.rb:688
msgid "More than 250 secondary organizations are not allowed."
msgstr ""
@ -10915,7 +10919,7 @@ msgstr ""
msgid "No template created yet."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:363
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:424
msgid "No tickets in this state."
msgstr ""
@ -10927,7 +10931,7 @@ msgstr ""
msgid "No translation for this locale available"
msgstr ""
#: app/models/ticket.rb:418
#: app/models/ticket.rb:419
msgid "No triggers active"
msgstr ""
@ -11059,7 +11063,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/cti.jst.eco:38
#: app/assets/javascripts/app/views/integration/placetel.jst.eco:54
#: app/assets/javascripts/app/views/integration/sipgate.jst.eco:47
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:103
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:102
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/note.ts:7
#: app/frontend/shared/entities/ticket-article/action/plugins/note.ts:14
#: db/seeds/object_manager_attributes.rb:1533
@ -11404,7 +11408,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/widget/ticket_stats.coffee:122
#: app/assets/javascripts/app/controllers/widget/user.coffee:70
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:118
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:120
#: db/seeds/overviews.rb:109
msgid "Open Tickets"
msgstr ""
@ -11734,7 +11738,7 @@ msgstr ""
msgid "Outdent text"
msgstr ""
#: app/frontend/apps/desktop/components/Form/fields/FieldEditor/FieldEditorActionBar/ActionToolbar.vue:185
#: app/frontend/apps/desktop/components/Form/fields/FieldEditor/FieldEditorActionBar/ActionToolbar.vue:200
msgid "Overflow menu"
msgstr ""
@ -11753,7 +11757,7 @@ msgstr ""
msgid "Overview navigation list"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:319
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:380
msgid "Overview: %s"
msgstr ""
@ -11807,15 +11811,15 @@ msgstr ""
msgid "PGP"
msgstr ""
#: db/seeds/settings.rb:5716
#: db/seeds/settings.rb:5753
msgid "PGP Recipient Alias Configuration"
msgstr ""
#: db/seeds/settings.rb:5702
#: db/seeds/settings.rb:5739
msgid "PGP config"
msgstr ""
#: db/seeds/settings.rb:5674
#: db/seeds/settings.rb:5711
msgid "PGP integration"
msgstr ""
@ -12146,7 +12150,7 @@ msgid "Please add categories and/or answers"
msgstr ""
#: app/assets/javascripts/app/views/customer_not_ticket_exists.jst.eco:12
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:349
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:410
msgid "Please click on the button below to create your first one."
msgstr ""
@ -12565,7 +12569,7 @@ msgid "Proxy address"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee:109
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:156
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:138
#: app/frontend/shared/entities/ticket/composables/useTicketEditForm.ts:157
msgid "Public"
msgstr ""
@ -13078,7 +13082,7 @@ msgstr ""
msgid "Requirements"
msgstr ""
#: db/seeds/settings.rb:5879
#: db/seeds/settings.rb:5916
msgid "Requires the setup of the two-factor authentication for certain user roles."
msgstr ""
@ -13244,7 +13248,7 @@ msgstr ""
msgid "Rewrite complex section and make it easy to understand"
msgstr ""
#: db/seeds/settings.rb:6067
#: db/seeds/settings.rb:6104
msgid "Richtext Bubble Menu"
msgstr ""
@ -13330,7 +13334,7 @@ msgstr ""
msgid "S/MIME (Secure/Multipurpose Internet Mail Extensions) is a widely accepted method (or more precisely, a protocol) for sending digitally signed and encrypted messages."
msgstr ""
#: db/seeds/settings.rb:5660
#: db/seeds/settings.rb:5697
msgid "S/MIME config"
msgstr ""
@ -13338,7 +13342,7 @@ msgstr ""
msgid "S/MIME enables you to send digitally signed and encrypted messages."
msgstr ""
#: db/seeds/settings.rb:5632
#: db/seeds/settings.rb:5669
msgid "S/MIME integration"
msgstr ""
@ -13437,7 +13441,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee:111
#: app/frontend/shared/composables/authentication/useThirdPartyAuthentication.ts:82
#: db/seeds/settings.rb:5748
#: db/seeds/settings.rb:5785
msgid "SSO"
msgstr ""
@ -13787,18 +13791,18 @@ msgid "Search…"
msgstr ""
#: app/frontend/apps/desktop/components/User/UserPopoverWithTrigger/UserPopover.vue:81
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:100
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:102
#: app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationCustomer.vue:88
#: app/frontend/apps/mobile/pages/user/views/UserDetailView.vue:133
#: db/seeds/object_manager_attributes.rb:1160
msgid "Secondary organizations"
msgstr ""
#: app/models/user.rb:675
#: app/models/user.rb:676
msgid "Secondary organizations are only allowed when the primary organization is given."
msgstr ""
#: app/models/user.rb:681
#: app/models/user.rb:682
msgid "Secondary organizations cannot include the primary organization."
msgstr ""
@ -13845,7 +13849,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/security_keys.coffee:4
#: app/assets/javascripts/app/lib/app_post/two_factor_methods/security_keys.coffee:5
#: app/frontend/shared/entities/two-factor/plugins/security-keys.ts:10
#: db/seeds/settings.rb:5785
#: db/seeds/settings.rb:5822
msgid "Security Keys"
msgstr ""
@ -14411,7 +14415,7 @@ msgstr ""
msgid "Show authenticator app secret"
msgstr ""
#: db/seeds/settings.rb:5757
#: db/seeds/settings.rb:5794
msgid "Show calendar weeks in the picker of date/datetime fields"
msgstr ""
@ -14979,7 +14983,7 @@ msgstr ""
msgid "Store"
msgstr ""
#: db/seeds/settings.rb:5993
#: db/seeds/settings.rb:6030
msgid "Stores the AI provider configuration."
msgstr ""
@ -14991,7 +14995,7 @@ msgstr ""
msgid "Stores the GitLab configuration."
msgstr ""
#: db/seeds/settings.rb:6023
#: db/seeds/settings.rb:6060
msgid "Stores the ticket summarization options (e.g. which content is visible)."
msgstr ""
@ -15423,7 +15427,7 @@ msgstr ""
msgid "Thanks for the feedback. Please explain what went wrong?"
msgstr ""
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:249
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:231
msgid "The %s selected tickets have been updated successfully."
msgstr ""
@ -16366,7 +16370,7 @@ msgstr ""
msgid "The server presented a certificate that could not be verified."
msgstr ""
#: lib/user_agent.rb:236
#: lib/user_agent.rb:235
msgid "The server returned a redirect response, but the current operation does not allow redirects."
msgstr ""
@ -16584,7 +16588,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/customer_ticket_create/sidebar_customer_default.coffee:29
#: app/assets/javascripts/app/views/customer_not_ticket_exists.jst.eco:11
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:346
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:407
msgid "The way to communicate with us is this thing called \"ticket\"."
msgstr ""
@ -16999,7 +17003,7 @@ msgstr ""
msgid "This is not a valid X509 certificate. Please check the certificate format."
msgstr ""
#: app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue:67
#: app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue:69
#: app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInputDropdownItem.vue:89
msgid "This item expands to show more options"
msgstr ""
@ -17295,7 +17299,7 @@ msgstr ""
msgid "Ticket Number ignore system_id"
msgstr ""
#: db/seeds/settings.rb:5901
#: db/seeds/settings.rb:5938
msgid "Ticket Organization Reassignment"
msgstr ""
@ -17374,11 +17378,11 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:2
#: app/assets/javascripts/app/views/ai/ticket_summary.jst.eco:7
#: db/seeds/permissions.rb:227
#: db/seeds/settings.rb:6006
#: db/seeds/settings.rb:6043
msgid "Ticket Summary"
msgstr ""
#: db/seeds/settings.rb:6020
#: db/seeds/settings.rb:6057
msgid "Ticket Summary Config"
msgstr ""
@ -17567,14 +17571,14 @@ msgstr ""
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/calendar.ts:8
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/notifications.ts:8
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/ticketOverviews.ts:8
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:109
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:111
#: app/frontend/apps/mobile/pages/search/plugins/ticket.ts:9
#: app/frontend/apps/mobile/pages/ticket/routes.ts:50
#: app/frontend/apps/mobile/pages/ticket/views/TicketOverview.vue:159
msgid "Tickets"
msgstr ""
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:300
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:282
msgid "Tickets Bulk Edit"
msgstr ""
@ -17883,7 +17887,7 @@ msgstr ""
msgid "Token-based API access has been disabled by the administrator."
msgstr ""
#: lib/user_agent.rb:240
#: lib/user_agent.rb:239
msgid "Too many redirections for the original URL, halting."
msgstr ""
@ -18159,7 +18163,7 @@ msgstr ""
msgid "Type:"
msgstr ""
#: db/seeds/settings.rb:5967
#: db/seeds/settings.rb:6004
msgid "UI Desktop Beta Switch"
msgstr ""
@ -18387,7 +18391,7 @@ msgstr ""
msgid "Update successful."
msgstr ""
#: db/seeds/settings.rb:5913
#: db/seeds/settings.rb:5950
msgid "Update the most recent tickets."
msgstr ""
@ -18435,7 +18439,7 @@ msgstr ""
#: app/assets/javascripts/app/models/user.coffee:17
#: app/frontend/shared/entities/organization/stores/objectAttributes.ts:41
#: app/frontend/shared/entities/ticket/stores/objectAttributes.ts:125
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:41
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:44
#: app/graphql/gql/types/overview_type.rb:115
msgid "Updated at"
msgstr ""
@ -18457,7 +18461,7 @@ msgstr ""
#: app/assets/javascripts/app/models/user.coffee:16
#: app/frontend/shared/entities/organization/stores/objectAttributes.ts:31
#: app/frontend/shared/entities/ticket/stores/objectAttributes.ts:114
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:31
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:33
#: app/graphql/gql/types/overview_type.rb:114
msgid "Updated by"
msgstr ""
@ -18978,7 +18982,7 @@ msgstr ""
#: app/assets/javascripts/app/models/ticket_article.coffee:14
#: app/assets/javascripts/app/views/generic/ticket_perform_action/article.jst.eco:3
#: app/assets/javascripts/app/views/generic/ticket_perform_action/notification.jst.eco:3
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:150
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:132
#: app/frontend/shared/entities/ticket/composables/useTicketEditForm.ts:144
#: db/seeds/object_manager_attributes.rb:449
msgid "Visibility"
@ -19126,7 +19130,7 @@ msgstr ""
#: app/assets/javascripts/app/views/customer_not_ticket_exists.jst.eco:2
#: app/assets/javascripts/app/views/welcome.jst.eco:2
#: app/frontend/apps/desktop/pages/guided-setup/views/GuidedSetupStart.vue:43
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:340
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:401
msgid "Welcome!"
msgstr ""
@ -19288,11 +19292,11 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/text_tool.coffee:16
#: app/frontend/shared/components/Form/fields/FieldEditor/features/ai-assistant-text-tools/AiAssistantLoadingBanner/AiAssistantLoadingBanner.vue:35
#: db/seeds/permissions.rb:233
#: db/seeds/settings.rb:6039
#: db/seeds/settings.rb:6076
msgid "Writing Assistant"
msgstr ""
#: db/seeds/settings.rb:6053
#: db/seeds/settings.rb:6090
msgid "Writing Assistant Fixed Instructions"
msgstr ""
@ -19599,7 +19603,7 @@ msgid "You have no unread messages"
msgstr ""
#: app/assets/javascripts/app/views/customer_not_ticket_exists.jst.eco:10
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:343
#: app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue:404
msgid "You have not created a ticket yet."
msgstr ""
@ -19781,7 +19785,7 @@ msgstr ""
msgid "Zammad Helpdesk"
msgstr ""
#: lib/user_agent.rb:126
#: lib/user_agent.rb:125
msgid "Zammad User Agent"
msgstr ""
@ -20001,7 +20005,7 @@ msgstr ""
msgid "closed"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:126
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:128
msgid "closed tickets"
msgstr ""
@ -20589,7 +20593,7 @@ msgstr ""
msgid "is the wrong length (should be 1 character)"
msgstr ""
#: app/models/user.rb:872
#: app/models/user.rb:873
msgid "is too long"
msgstr ""
@ -20856,7 +20860,7 @@ msgstr ""
msgid "open"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:117
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:119
msgid "open tickets"
msgstr ""

View file

@ -143,6 +143,7 @@
"@tiptap/extension-text": "3.10.4",
"@tiptap/extension-text-style": "3.10.4",
"@tiptap/extension-unique-id": "3.10.4",
"@tiptap/extension-placeholder": "3.10.4",
"@tiptap/pm": "3.10.4",
"@tiptap/starter-kit": "3.10.4",
"@tiptap/suggestion": "3.10.4",

View file

@ -112,6 +112,9 @@ importers:
'@tiptap/extension-paragraph':
specifier: 3.10.4
version: 3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))
'@tiptap/extension-placeholder':
specifier: 3.10.4
version: 3.10.4(@tiptap/extensions@3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))(@tiptap/pm@3.10.4))
'@tiptap/extension-strike':
specifier: 3.10.4
version: 3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))
@ -2633,6 +2636,11 @@ packages:
peerDependencies:
'@tiptap/core': ^3.10.4
'@tiptap/extension-placeholder@3.10.4':
resolution: {integrity: sha512-3l3OxrUKKieUimRmL2q9GNmr0CYzAXdfkCOQUEzwvTXY+5DLqs/E6ECQVXktrIBnKAP9hmGy0nGvptfXYyEx1A==}
peerDependencies:
'@tiptap/extensions': ^3.10.4
'@tiptap/extension-strike@3.10.4':
resolution: {integrity: sha512-Y+M2TrlQKAIbP4XR8TQ1ZUpfnEWVKCFvVvh740MTgJRiAtLPdQz37XeT0pIWGkY0XRyFRssKYgiozJGuxU2TpQ==}
peerDependencies:
@ -5252,9 +5260,6 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -7434,7 +7439,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.4
'@babel/types': 7.28.5
'@babel/helper-compilation-targets@7.23.6':
dependencies:
@ -7512,7 +7517,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -7523,7 +7528,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -7540,7 +7545,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.5
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
@ -7551,7 +7556,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.28.4
'@babel/types': 7.28.5
'@babel/helper-plugin-utils@7.27.1': {}
@ -7594,7 +7599,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -7616,7 +7621,7 @@ snapshots:
dependencies:
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -7983,7 +7988,7 @@ snapshots:
'@babel/core': 7.24.5
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.24.5)
'@babel/helper-plugin-utils': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
@ -8271,7 +8276,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.27.1
'@babel/types': 7.28.4
'@babel/types': 7.28.5
esutils: 2.0.3
'@babel/runtime-corejs3@7.26.0':
@ -9746,6 +9751,10 @@ snapshots:
dependencies:
'@tiptap/core': 3.10.4(@tiptap/pm@3.10.4)
'@tiptap/extension-placeholder@3.10.4(@tiptap/extensions@3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))(@tiptap/pm@3.10.4))':
dependencies:
'@tiptap/extensions': 3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))(@tiptap/pm@3.10.4)
'@tiptap/extension-strike@3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))':
dependencies:
'@tiptap/core': 3.10.4(@tiptap/pm@3.10.4)
@ -12710,10 +12719,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
magic-string@0.30.19:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::Organization::NoteUpdate, type: :graphql do
context 'when updating organizations', authenticated_as: :user do
let(:user) { create(:agent, preferences: { locale: 'de-de' }) }
let(:organization) { create(:organization) }
let(:variables) { { id: gql.id(organization), note: } }
let(:note) { 'This is a test note.' }
let(:query) do
<<~QUERY
mutation organizationNoteUpdate($id: ID!, $note: String!) {
organizationNoteUpdate(id: $id, note: $note) {
organization {
id
note
}
errors {
message
field
}
}
}
QUERY
end
before do
gql.execute(query, variables: variables)
end
it 'returns updated organization' do
expect(gql.result.data[:organization]).to include('note' => note)
end
context 'when trying to update without having correct permissions' do
let(:user) { create(:customer) }
it 'raises an error' do
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
end
end
end
end

View file

@ -5,8 +5,8 @@ require 'rails_helper'
RSpec.describe Gql::Mutations::Ticket::TitleUpdate, :aggregate_failures, type: :graphql do
let(:query) do
<<~QUERY
mutation ticketTitleUpdate($ticketId: ID!, $input: TicketTitleUpdateInput!) {
ticketTitleUpdate(ticketId: $ticketId, input: $input) {
mutation ticketTitleUpdate($ticketId: ID!, $title: String!) {
ticketTitleUpdate(ticketId: $ticketId, title: $title) {
ticket {
id
title
@ -22,8 +22,7 @@ RSpec.describe Gql::Mutations::Ticket::TitleUpdate, :aggregate_failures, type: :
let(:agent) { create(:agent, groups: [ticket.group]) }
let(:title) { 'Updated Ticket Title' }
let(:ticket) { create(:ticket) }
let(:input_payload) { { title: } }
let(:variables) { { ticketId: gql.id(ticket), input: input_payload } }
let(:variables) { { ticketId: gql.id(ticket), title: } }
let(:expected_base_response) do
{
'id' => gql.id(Ticket.last),

View file

@ -0,0 +1,71 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::User::NoteUpdate, type: :graphql do
context 'when updating a user', authenticated_as: :agent do
let(:agent) { create(:agent) }
let(:user) { create(:user, :with_org) }
let(:note) { 'This is a test note.' }
let(:variables) do
{
id: gql.id(user),
note:
}
end
let(:query) do
<<~QUERY
mutation userNoteUpdate($id: ID!, $note: String!) {
userNoteUpdate(id: $id, note: $note) {
user {
id
note
}
errors {
message
field
}
}
}
QUERY
end
let(:expected_response) do
{
'id' => gql.id(user),
'note' => note,
}
end
it 'updates User record' do
gql.execute(query, variables: variables)
expect(gql.result.data[:user]).to eq(expected_response)
end
context 'without permission', authenticated_as: :user do
context 'with not authorized agent' do
let(:user) { create(:admin, roles: [role]) }
let(:role) do
role = create(:role)
role.permission_grant('admin.branding')
role
end
it 'raises an error' do
gql.execute(query, variables: variables)
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
end
end
context 'with customer' do
let(:user) { create(:customer) }
it 'raises an error' do
gql.execute(query, variables: variables)
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
end
end
end
end
end

View file

@ -6,6 +6,10 @@ RSpec.configure do |config|
logs = page.driver.browser.logs.get(:browser)
errors = logs.select { |m| m.level == 'SEVERE' && m.to_s =~ %r{EvalError|InternalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|E60(0|1)} }
# FIXME: Ignore certain unexplained JS errors that happen in some tests.
# - 1:37680 Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
errors = errors.filter { |e| e.message !~ %r{Uncaught TypeError: Cannot read properties of undefined \(reading 'toUpperCase'\)$} }
if errors.present?
Rails.logger.error "JS ERRORS: #{errors.to_json}"
errors.each do |error|

View file

@ -300,14 +300,16 @@ class ZammadFormFieldCapybaraElementDelegator < SimpleDelegator
self # support chaining
end
def type_editor(text, click: true)
def type_editor(text, click: true, skip_waiting: false, wait_for: nil)
raise 'Field does not support typing' if !type_editor?
cursor_home_shortcut = mac_platform? ? %i[command up] : %i[control home]
input_element.click.send_keys(cursor_home_shortcut) if click
input_element.send_keys(text)
maybe_wait_for_form_updater
maybe_wait_for_form_updater if !skip_waiting
sleep wait_for if wait_for
self # support chaining
end

View file

@ -28,7 +28,10 @@ RSpec.describe 'Desktop > Ticket > Create', app: :desktop_view, authenticated_as
find_autocomplete('CC').search_for_option(Faker::Internet.unique.email, use_action: true)
text = find_editor('Text')
text.type('# ').type('Heading').type(:enter, click: false)
text.type('# ', skip_waiting: true, wait_for: 0.1) # markdown shortcut, editor is still considered empty
.type('Heading', click: false)
.type(:enter, click: false)
find('button[aria-label="Format as bold"]').click
text.type('Bold Text ', click: false).type(:enter, click: false)

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