Browse Source

Maintenance: Desktop - Add guided setup invite agent screen.

Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Dusan Vuckovic 1 year ago
parent
commit
bf591bfe82

+ 0 - 3
app/controllers/concerns/checks_user_attributes_by_current_user_permission.rb

@@ -8,9 +8,6 @@ module ChecksUserAttributesByCurrentUserPermission
   def check_attributes_by_current_user_permission(params)
     authorize!
 
-    # admins can do whatever they want
-    return true if current_user.permissions?('admin.user')
-
     Service::User::FilterPermissionAssignments.new(current_user: current_user).execute(user_data: params)
   end
 end

+ 125 - 61
app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/FieldGroupPermissionsInput.vue

@@ -1,17 +1,17 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 <script setup lang="ts">
-import { computed, onMounted, reactive, toRef, watch } from 'vue'
-import { cloneDeep } from 'lodash-es'
+import { computed, reactive, toRef, watch } from 'vue'
+import { cloneDeep, isEqual } from 'lodash-es'
 import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
 import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
 import type { TreeSelectOption } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
 import useValue from '#shared/components/Form/composables/useValue.ts'
 import { useDelegateFocus } from '#shared/composables/useDelegateFocus.ts'
 import useFlatSelectOptions from '../FieldTreeSelect/useFlatSelectOptions.ts'
-import type {
-  GroupAccessLookup,
-  GroupPermissionReactive,
-  GroupPermissionsContext,
+import {
+  GroupAccess,
+  type GroupPermissionReactive,
+  type GroupPermissionsContext,
 } from './types.ts'
 
 interface Props {
@@ -24,31 +24,40 @@ const contextReactive = toRef(props, 'context')
 
 const { localValue } = useValue(contextReactive)
 
-const { flatOptions } = useFlatSelectOptions(toRef(props.context, 'groups'))
+const { flatOptions } = useFlatSelectOptions(toRef(props.context, 'options'))
 
-const groupPermissions = computed<GroupPermissionReactive[]>({
-  get() {
-    return localValue.value || []
-  },
+const groupPermissions = reactive<GroupPermissionReactive[]>([])
+const groupOptions = reactive<TreeSelectOption[][]>([])
 
-  set(value) {
-    localValue.value = value
+const groupAccesses = [
+  {
+    access: GroupAccess.Read,
+    label: __('Read'),
   },
-})
-
-const groupOptions = reactive<TreeSelectOption[][]>([])
+  {
+    access: GroupAccess.Create,
+    label: __('Create'),
+  },
+  {
+    access: GroupAccess.Change,
+    label: __('Change'),
+  },
+  {
+    access: GroupAccess.Overview,
+    label: __('Overview'),
+  },
+  {
+    access: GroupAccess.Full,
+    label: __('Full'),
+  },
+]
 
 const getTakenGroups = (index: number): SelectValue[] =>
-  groupPermissions.value.reduce(
-    (takenGroups, groupPermission, currentIndex) => {
-      if (currentIndex !== index && groupPermission.groups)
-        takenGroups.push(
-          ...(groupPermission.groups as unknown as SelectValue[]),
-        )
-      return takenGroups
-    },
-    [] as SelectValue[],
-  )
+  groupPermissions.reduce((takenGroups, groupPermission, currentIndex) => {
+    if (currentIndex !== index && groupPermission.groups)
+      takenGroups.push(...(groupPermission.groups as unknown as SelectValue[]))
+    return takenGroups
+  }, [] as SelectValue[])
 
 const filterTreeSelectOptions = (options: TreeSelectOption[], index: number) =>
   options.filter((group) => {
@@ -72,63 +81,92 @@ const filterTreeSelectOptions = (options: TreeSelectOption[], index: number) =>
   })
 
 const filterGroupOptions = (index: number) =>
-  filterTreeSelectOptions(cloneDeep(contextReactive.value.groups), index)
+  filterTreeSelectOptions(cloneDeep(contextReactive.value.options || []), index)
 
-const getNewGroupPermission = () =>
-  reactive<GroupPermissionReactive>({
-    groups: [] as unknown as SelectValue,
-    groupAccess: contextReactive.value.groupAccesses.reduce(
-      (groupAccess, { access }) => {
-        groupAccess[access] = false
+const getNewGroupPermission = () => ({
+  groups: [] as unknown as SelectValue,
+  groupAccess: groupAccesses.reduce(
+    (groupAccess, { access }) => {
+      groupAccess[access] = false
 
-        return groupAccess
-      },
-      {} as GroupAccessLookup,
-    ),
-  })
+      return groupAccess
+    },
+    {} as Record<GroupAccess, boolean>,
+  ),
+})
 
 const addGroupPermission = (index: number) => {
   groupOptions[index] = filterGroupOptions(index)
-  groupPermissions.value.splice(index, 0, getNewGroupPermission())
+  groupPermissions.splice(index, 0, getNewGroupPermission())
 }
 
 const removeGroupPermission = (index: number) => {
-  groupPermissions.value.splice(index, 1)
+  groupPermissions.splice(index, 1)
   groupOptions.splice(index, 1)
 }
 
 watch(
-  () => groupPermissions.value,
-  () => {
-    groupPermissions.value.forEach((_groupPermission, index) => {
+  groupPermissions,
+  (newValue) => {
+    // Set external value to internal one, but only if they differ (loop protection).
+    if (isEqual(newValue, localValue.value)) return
+
+    newValue.forEach((_groupPermission, index) => {
       groupOptions[index] = filterGroupOptions(index)
     })
+
+    localValue.value = cloneDeep(newValue)
   },
   {
-    immediate: true,
     deep: true,
   },
 )
 
-onMounted(() => {
-  if (groupPermissions.value.length) return
+watch(
+  localValue,
+  (newValue) => {
+    if (!newValue || !newValue.length) {
+      groupOptions.splice(0, groupOptions.length, filterGroupOptions(0))
 
-  contextReactive.value.node.input([getNewGroupPermission()])
-})
+      groupPermissions.splice(
+        0,
+        groupPermissions.length,
+        getNewGroupPermission(),
+      )
+
+      return
+    }
+
+    // Set internal value to external one, but only if they differ (loop protection).
+    if (isEqual(newValue, groupPermissions)) return
+    ;(newValue as GroupPermissionReactive[]).forEach(
+      (_groupPermission, index) => {
+        groupOptions[index] = filterGroupOptions(index)
+      },
+    )
 
-const hasLastGroupPermission = computed(
-  () => groupPermissions.value.length === 1,
+    groupPermissions.splice(
+      0,
+      groupPermissions.length,
+      ...cloneDeep(newValue || []),
+    )
+  },
+  {
+    immediate: true,
+  },
 )
 
+const hasLastGroupPermission = computed(() => groupPermissions.length === 1)
+
 const hasNoMoreGroups = computed(
   () =>
     !flatOptions.value.length ||
-    groupPermissions.value.reduce((emptyGroups, groupPermission) => {
+    groupPermissions.reduce((emptyGroups, groupPermission) => {
       if (!((groupPermission.groups as unknown as SelectValue[]) || []).length)
         emptyGroups += 1
       return emptyGroups
     }, 0) > 0 ||
-    groupPermissions.value.reduce(
+    groupPermissions.reduce(
       (selectedGroupCount, groupPermission) =>
         selectedGroupCount +
         ((groupPermission.groups as unknown as SelectValue[]) || []).length,
@@ -140,12 +178,32 @@ const { delegateFocus } = useDelegateFocus(
   contextReactive.value.id,
   `${contextReactive.value.id}_first_element`,
 )
+
+const ensureGranularOrFullAccess = (
+  groupAccess: Record<GroupAccess, boolean>,
+  access: GroupAccess,
+  value: boolean,
+) => {
+  if (value === false) return
+
+  if (access === GroupAccess.Full && value === true) {
+    Object.entries(groupAccess).forEach(([key, state]) => {
+      if (key !== GroupAccess.Full && state === true) {
+        groupAccess[key as GroupAccess] = false
+      }
+    })
+  } else if (
+    access !== GroupAccess.Full &&
+    groupAccess[GroupAccess.Full] === true
+  )
+    groupAccess[GroupAccess.Full] = false
+}
 </script>
 
 <template>
   <output
     :id="context.id"
-    class="w-full flex flex-col p-2 space-y-2 focus:outline-none"
+    class="w-full flex flex-col p-2 space-y-2 rounded-lg focus:outline 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"
@@ -156,12 +214,8 @@ const { delegateFocus } = useDelegateFocus(
   >
     <div
       v-for="(groupPermission, index) in groupPermissions"
-      :key="
-        ((groupPermission.groups as unknown as SelectValue[]) || []).length
-          ? `group-permission-groupId-${(groupPermission.groups as unknown as SelectValue[]).join('-')}`
-          : `group-permission-index-${index}`
-      "
-      class="w-full flex items-center gap-5"
+      :key="`group-permission-index-${index}`"
+      class="w-full flex items-center gap-3"
       role="listitem"
     >
       <FormKit
@@ -169,21 +223,31 @@ const { delegateFocus } = useDelegateFocus(
         v-model="groupPermission.groups"
         type="treeselect"
         outer-class="grow"
+        :ignore="true"
         :options="groupOptions[index]"
         :clearable="true"
         :multiple="true"
         :disabled="context.disabled"
         :alternative-background="true"
+        :no-options-label-translation="true"
         @blur="index === 0 ? context.handlers.blur : undefined"
       />
       <FormKit
-        v-for="groupAccess in context.groupAccesses"
+        v-for="groupAccess in groupAccesses"
         :key="groupAccess.access"
         v-model="groupPermission.groupAccess[groupAccess.access]"
         type="checkbox"
-        wrapper-class="w-full flex-col-reverse"
+        wrapper-class="shrink-0 flex-col-reverse"
+        :ignore="true"
         :disabled="context.disabled"
         :alternative-border="true"
+        @input="
+          ensureGranularOrFullAccess(
+            groupPermission.groupAccess,
+            groupAccess.access,
+            $event!,
+          )
+        "
       >
         <template #label>
           <CommonLabel class="uppercase text-gray-300" size="small">

+ 93 - 23
app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/__tests__/FieldGroupPermissionsInput.spec.ts → app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/__tests__/FieldGroupPermissions.spec.ts

@@ -32,7 +32,7 @@ const renderGroupPermissionsInput = async (
 }
 
 const commonProps = {
-  groups: [
+  options: [
     {
       value: 1,
       label: 'Users',
@@ -48,28 +48,6 @@ const commonProps = {
       ],
     },
   ],
-  groupAccesses: [
-    {
-      access: 'read',
-      label: 'Read',
-    },
-    {
-      access: 'create',
-      label: 'Create',
-    },
-    {
-      access: 'change',
-      label: 'Change',
-    },
-    {
-      access: 'overview',
-      label: 'Overview',
-    },
-    {
-      access: 'full',
-      label: 'Full',
-    },
-  ],
 }
 
 describe('Fields - FieldGroupPermissions', () => {
@@ -313,6 +291,98 @@ describe('Fields - FieldGroupPermissions', () => {
       queryByRole(listbox, 'button', { name: 'Has submenu' }),
     ).toBeInTheDocument()
   })
+
+  it('ensures either granular or full access is selected', async () => {
+    const view = await renderGroupPermissionsInput(commonProps)
+
+    await view.events.click(view.getByLabelText('Read'))
+
+    await waitFor(() => {
+      expect(getNode('groupPermissions')?.value).toEqual([
+        expect.objectContaining({
+          groupAccess: {
+            read: true,
+            create: false,
+            change: false,
+            overview: false,
+            full: false,
+          },
+        }),
+      ])
+    })
+
+    await view.events.click(view.getByLabelText('Full'))
+
+    await waitFor(() => {
+      expect(getNode('groupPermissions')?.value).toEqual([
+        expect.objectContaining({
+          groupAccess: {
+            read: false,
+            create: false,
+            change: false,
+            overview: false,
+            full: true,
+          },
+        }),
+      ])
+    })
+
+    await view.events.click(view.getByLabelText('Read'))
+    await view.events.click(view.getByLabelText('Create'))
+    await view.events.click(view.getByLabelText('Change'))
+    await view.events.click(view.getByLabelText('Overview'))
+
+    await waitFor(() => {
+      expect(getNode('groupPermissions')?.value).toEqual([
+        expect.objectContaining({
+          groupAccess: {
+            read: true,
+            create: true,
+            change: true,
+            overview: true,
+            full: false,
+          },
+        }),
+      ])
+    })
+
+    await view.events.click(view.getByLabelText('Full'))
+
+    await waitFor(() => {
+      expect(getNode('groupPermissions')?.value).toEqual([
+        expect.objectContaining({
+          groupAccess: {
+            read: false,
+            create: false,
+            change: false,
+            overview: false,
+            full: true,
+          },
+        }),
+      ])
+    })
+  })
+
+  it('does not translate group names', async () => {
+    const testOptions = [
+      {
+        value: 1,
+        label: 'Group name (%s)',
+        labelPlaceholder: ['translated'],
+      },
+    ]
+
+    const view = await renderGroupPermissionsInput({
+      options: testOptions,
+    })
+
+    await view.events.click(view.getByRole('combobox'))
+
+    const listbox = view.getByRole('listbox')
+    const options = getAllByRole(listbox, 'option')
+
+    expect(options[0]).toHaveTextContent(testOptions[0].label)
+  })
 })
 
 // Cover all use cases from the FormKit custom input checklist.

+ 1 - 4
app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/index.ts

@@ -3,10 +3,7 @@
 import createInput from '#shared/form/core/createInput.ts'
 import FieldGroupPermissionsInput from './FieldGroupPermissionsInput.vue'
 
-const fieldDefinition = createInput(FieldGroupPermissionsInput, [
-  'groups',
-  'groupAccesses',
-])
+const fieldDefinition = createInput(FieldGroupPermissionsInput, ['options'])
 
 export default {
   fieldType: 'groupPermissions',

+ 8 - 10
app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/types.ts

@@ -4,21 +4,19 @@ import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
 import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
 import type { TreeSelectOption } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
 
-export interface GroupAccess {
-  access: string
-  label: string
+export enum GroupAccess {
+  Read = 'read',
+  Create = 'create',
+  Change = 'change',
+  Overview = 'overview',
+  Full = 'full',
 }
 
 export type GroupPermissionsContext = FormFieldContext<{
-  groups: TreeSelectOption[]
-  groupAccesses: GroupAccess[]
+  options: TreeSelectOption[]
 }>
 
-export interface GroupAccessLookup {
-  [access: string]: boolean
-}
-
 export interface GroupPermissionReactive {
   groups: SelectValue
-  groupAccess: GroupAccessLookup
+  groupAccess: Record<GroupAccess, boolean>
 }

+ 0 - 0
app/frontend/apps/desktop/components/Form/fields/FieldImageUpload/__tests__/FieldImageUploadInput.spec.ts → app/frontend/apps/desktop/components/Form/fields/FieldImageUpload/__tests__/FieldImageUpload.spec.ts


+ 2 - 2
app/frontend/apps/desktop/components/Form/fields/FieldSelect/FieldSelectInput.vue

@@ -221,7 +221,7 @@ setupMissingOrDisabledOptionHandling()
                 decorative
               />
               <span
-                class="line-clamp-3"
+                class="line-clamp-3 whitespace-pre-wrap break-words"
                 :title="
                   getSelectedOptionLabel(selectedValue) ||
                   i18n.t('%s (unknown)', selectedValue)
@@ -272,7 +272,7 @@ setupMissingOrDisabledOptionHandling()
               decorative
             />
             <span
-              class="line-clamp-3"
+              class="line-clamp-3 whitespace-pre-wrap break-words"
               :title="
                 getSelectedOptionLabel(currentValue) ||
                 i18n.t('%s (unknown)', currentValue)

+ 5 - 3
app/frontend/apps/desktop/components/Form/fields/FieldToggleList/FieldToggleListInput.vue

@@ -2,6 +2,7 @@
 
 <script setup lang="ts">
 import { computed, toRef } from 'vue'
+import { cloneDeep } from 'lodash-es'
 import useValue from '#shared/components/Form/composables/useValue.ts'
 import { i18n } from '#shared/i18n.ts'
 import { useDelegateFocus } from '#shared/composables/useDelegateFocus.ts'
@@ -32,7 +33,7 @@ const updateValue = (
   key: ToggleListOptionValue,
   state: boolean | undefined,
 ) => {
-  const values: ToggleListOptionValue[] = localValue.value || []
+  const values: ToggleListOptionValue[] = cloneDeep(localValue.value) || []
 
   if (state === true && !values.includes(key)) {
     values.push(key)
@@ -44,14 +45,14 @@ const updateValue = (
 
 const { delegateFocus } = useDelegateFocus(
   context.value.id,
-  `toggle_list_toggle_${context.value.id}_${context.value.options[0]?.value}`,
+  `toggle_list_toggle_${context.value.id}_${context.value?.options && context.value?.options[0]?.value}`,
 )
 </script>
 
 <template>
   <output
     :id="context.id"
-    class="block bg-blue-200 dark:bg-gray-700 rounded-lg focus:outline-none"
+    class="block bg-blue-200 dark:bg-gray-700 rounded-lg focus:outline focus:outline-1 focus:outline-offset-1 focus:outline-blue-800 hover:focus:outline-blue-800"
     role="list"
     :class="context.classes.input"
     :name="context.node.name"
@@ -71,6 +72,7 @@ const { delegateFocus } = useDelegateFocus(
         :model-value="valueLookup[option.value]"
         type="toggle"
         :name="`toggle_list_toggle_${context.id}_${option.value}`"
+        :ignore="true"
         wrapper-class="gap-2.5"
         :variants="{ true: 'True', false: 'False' }"
         :disabled="context.disabled"

+ 0 - 0
app/frontend/apps/desktop/components/Form/fields/FieldToggleList/__tests__/FieldToggleListInput.spec.ts → app/frontend/apps/desktop/components/Form/fields/FieldToggleList/__tests__/FieldToggleList.spec.ts


+ 2 - 2
app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInput.vue

@@ -291,7 +291,7 @@ setupMissingOrDisabledOptionHandling()
                 decorative
               />
               <span
-                class="line-clamp-3"
+                class="line-clamp-3 whitespace-pre-wrap break-words"
                 :title="getSelectedOptionFullPath(selectedValue)"
               >
                 {{ getSelectedOptionFullPath(selectedValue) }}
@@ -336,7 +336,7 @@ setupMissingOrDisabledOptionHandling()
               decorative
             />
             <span
-              class="line-clamp-3"
+              class="line-clamp-3 whitespace-pre-wrap break-words"
               :title="getSelectedOptionFullPath(currentValue)"
             >
               {{ getSelectedOptionFullPath(currentValue) }}

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