Browse Source

Feature: Mobile - Add custom autocomplete form field

Dusan Vuckovic 2 years ago
parent
commit
e1ccab7240

+ 5 - 0
app/assets/stylesheets/svg-dimensions.css

@@ -3,6 +3,7 @@
 .icon-arrow-left { width: 7px; height: 13px; }
 .icon-arrow-right { width: 7px; height: 13px; }
 .icon-arrow-up { width: 13px; height: 7px; }
+.icon-bell { width: 33px; height: 32px; }
 .icon-bold { width: 12px; height: 12px; }
 .icon-caret-down { width: 24px; height: 24px; }
 .icon-chain { width: 16px; height: 16px; }
@@ -61,6 +62,7 @@
 .icon-group { width: 24px; height: 24px; }
 .icon-hashtag { width: 28px; height: 28px; }
 .icon-help { width: 16px; height: 16px; }
+.icon-home { width: 22px; height: 23px; }
 .icon-horizontal-rule { width: 12px; height: 12px; }
 .icon-important { width: 16px; height: 16px; }
 .icon-in-process { width: 64px; height: 64px; }
@@ -97,6 +99,8 @@
 .icon-mood-superbad { width: 60px; height: 59px; }
 .icon-mood-supergood { width: 60px; height: 59px; }
 .icon-mute { width: 16px; height: 16px; }
+.icon-new-customer { width: 24px; height: 24px; }
+.icon-new-organization { width: 24px; height: 24px; }
 .icon-not-signed { width: 14px; height: 14px; }
 .icon-note { width: 16px; height: 16px; }
 .icon-oauth2-button { width: 29px; height: 24px; }
@@ -131,6 +135,7 @@
 .icon-spinner-small { width: 15px; height: 15px; }
 .icon-split { width: 16px; height: 17px; }
 .icon-sso-button { width: 29px; height: 24px; }
+.icon-stack { width: 24px; height: 24px; }
 .icon-state-closed { width: 24px; height: 24px; }
 .icon-state-escalated { width: 24px; height: 24px; }
 .icon-state-open { width: 24px; height: 24px; }

+ 15 - 12
app/frontend/apps/mobile/components/CommonDialog/CommonDialog.vue

@@ -61,6 +61,7 @@ export default {
       <div
         class="relative flex h-16 shrink-0 select-none items-center justify-center rounded-t-xl bg-gray-600/80"
       >
+        <slot name="before-label" />
         <div
           class="grow text-center text-base font-semibold leading-[19px] text-white"
         >
@@ -68,19 +69,21 @@ export default {
             {{ i18n.t(label) }}
           </slot>
         </div>
-        <div class="absolute top-0 right-0 bottom-0 flex items-center pr-4">
-          <div
-            class="grow cursor-pointer text-blue"
-            tabindex="0"
-            role="button"
-            v-bind="listeners?.done"
-            @pointerdown.stop
-            @click="close()"
-            @keypress.space="close()"
-          >
-            {{ i18n.t('Done') }}
+        <slot name="after-label">
+          <div class="absolute top-0 right-0 bottom-0 flex items-center pr-4">
+            <div
+              class="grow cursor-pointer text-blue"
+              tabindex="0"
+              role="button"
+              v-bind="listeners?.done"
+              @pointerdown.stop
+              @click="close()"
+              @keypress.space="close()"
+            >
+              {{ i18n.t('Done') }}
+            </div>
           </div>
-        </div>
+        </slot>
       </div>
       <div
         class="flex grow flex-col items-start overflow-y-auto bg-black text-white"

+ 20 - 2
app/frontend/apps/mobile/form/theme/global/getCoreClasses.ts

@@ -75,8 +75,26 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
       outer: `${classes.select && classes.select.outer} field-select`,
     }),
     treeselect: addFloatingLabel({
-      ...(classes.select || {}),
-      outer: `${classes.select && classes.select.outer} field-treeselect`,
+      ...(classes.treeselect || {}),
+      outer: `${
+        classes.treeselect && classes.treeselect.outer
+      } field-treeselect`,
+    }),
+    autocomplete: addFloatingLabel({
+      ...(classes.autocomplete || {}),
+      outer: `${
+        classes.autocomplete && classes.autocomplete.outer
+      } field-autocomplete`,
+    }),
+    customer: addFloatingLabel({
+      ...(classes.customer || {}),
+      outer: `${classes.customer && classes.customer.outer} field-customer`,
+    }),
+    organization: addFloatingLabel({
+      ...(classes.organization || {}),
+      outer: `${
+        classes.organization && classes.organization.outer
+      } field-organization`,
     }),
     button: addButtonVariants(classes.button),
     submit: addButtonVariants(classes.submit),

+ 26 - 1
app/frontend/apps/mobile/modules/playground/views/PlaygroundOverview.vue

@@ -51,7 +51,7 @@ const linkSchemaRaw = [
   {
     type: 'treeselect',
     name: 'treeselect',
-    label: 'Treeselect',
+    label: 'TreeSelect',
     props: {
       options: [
         {
@@ -104,6 +104,31 @@ const linkSchemaRaw = [
       link: '/tickets',
     },
   },
+  {
+    type: 'autocomplete',
+    name: 'autocomplete',
+    label: 'AutoComplete',
+    props: {
+      // options: [{ label: 'Label', value: 1 }],
+      sorting: 'label',
+      link: '/tickets',
+      action: '/tickets',
+      actionIcon: 'new-customer',
+      gqlQuery: `
+query autocompleteSearchUser($query: String!, $limit: Int) {
+  autocompleteSearchUser(query: $query, limit: $limit) {
+    value
+    label
+    labelPlaceholder
+    heading
+    headingPlaceholder
+    disabled
+    icon
+  }
+}
+`,
+    },
+  },
 ]
 const linkSchemas = defineFormSchema(linkSchemaRaw)
 

+ 2 - 2
app/frontend/shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue

@@ -7,7 +7,7 @@ import { TicketState } from '@shared/entities/ticket/types'
 // TODO: Add a test and story for this common component.
 
 export interface Props {
-  status: TicketState | string
+  status?: TicketState | string
   label: string
   pill?: boolean
 }
@@ -19,7 +19,7 @@ const props = withDefaults(defineProps<Props>(), {
 const states = new Set<string>(Object.values(TicketState))
 
 const statusIndicator = computed(() => {
-  if (!states.has(props.status)) {
+  if (!props.status || !states.has(props.status)) {
     return ''
   }
 

+ 4 - 0
app/frontend/shared/components/CommonUserAvatar/CommonUserAvatar.vue

@@ -55,6 +55,10 @@ const icon = computed(() => {
 
 const image = computed(() => {
   if (icon.value || !props.entity.image) return null
+
+  // Support the inline data URI as an image source.
+  if (/^data:/.test(props.entity.image)) return props.entity.image
+
   // TODO see how we will do this when API will be in Graphql Context
   const apiUrl = '/api/v1'
   return `${apiUrl}/users/image/${props.entity.image}`

+ 31 - 9
app/frontend/shared/components/Form/composables/useSelectOptions.ts

@@ -6,10 +6,11 @@ import type { TicketState } from '@shared/entities/ticket/types'
 import type { SelectOptionSorting, SelectOption } from '../fields/FieldSelect'
 import type { FormFieldContext } from '../types/field'
 import type { FlatSelectOption } from '../fields/FieldTreeSelect'
+import type { AutoCompleteOption } from '../fields/FieldAutoComplete'
 import useValue from './useValue'
 
 const useSelectOptions = (
-  options: Ref<SelectOption[] | FlatSelectOption[]>,
+  options: Ref<SelectOption[] | FlatSelectOption[] | AutoCompleteOption[]>,
   context: Ref<
     FormFieldContext<{
       multiple?: boolean
@@ -18,11 +19,11 @@ const useSelectOptions = (
     }>
   >,
   arrowLeftCallback?: (
-    option?: SelectOption | FlatSelectOption,
+    option?: SelectOption | FlatSelectOption | AutoCompleteOption,
     getDialogFocusTargets?: (optionsOnly?: boolean) => HTMLElement[],
   ) => void,
   arrowRightCallback?: (
-    option?: SelectOption | FlatSelectOption,
+    option?: SelectOption | FlatSelectOption | AutoCompleteOption,
     getDialogFocusTargets?: (optionsOnly?: boolean) => HTMLElement[],
   ) => void,
 ) => {
@@ -31,25 +32,43 @@ const useSelectOptions = (
   const { currentValue } = useValue(context)
 
   const hasStatusProperty = computed(
-    () => options.value && options.value.some((option) => option.status),
+    () =>
+      options.value &&
+      options.value.some(
+        (option) => (option as SelectOption | FlatSelectOption).status,
+      ),
   )
 
   const translatedOptions = computed(
     () =>
       options.value &&
       options.value.map(
-        (option: SelectOption | FlatSelectOption) =>
+        (option: SelectOption | FlatSelectOption | AutoCompleteOption) =>
           ({
             ...option,
             label: context.value.noOptionsLabelTranslation
               ? option.label
               : i18n.t(option.label, option.labelPlaceholder as never),
-          } as unknown as SelectOption | FlatSelectOption),
+            ...((option as AutoCompleteOption).heading
+              ? {
+                  heading: context.value.noOptionsLabelTranslation
+                    ? (option as AutoCompleteOption).heading
+                    : i18n.t(
+                        (option as AutoCompleteOption).heading,
+                        (option as AutoCompleteOption)
+                          .headingPlaceholder as never,
+                      ),
+                }
+              : {}),
+          } as unknown as SelectOption | FlatSelectOption | AutoCompleteOption),
       ),
   )
 
   const optionValueLookup: ComputedRef<
-    Record<string | number, SelectOption | FlatSelectOption>
+    Record<
+      string | number,
+      SelectOption | FlatSelectOption | AutoCompleteOption
+    >
   > = computed(
     () =>
       translatedOptions.value &&
@@ -92,9 +111,12 @@ const useSelectOptions = (
 
   const getSelectedOptionStatus = (selectedValue: string | number) =>
     optionValueLookup.value[selectedValue] &&
-    (optionValueLookup.value[selectedValue].status as TicketState)
+    ((optionValueLookup.value[selectedValue] as SelectOption | FlatSelectOption)
+      .status as TicketState)
 
-  const selectOption = (option: SelectOption | FlatSelectOption) => {
+  const selectOption = (
+    option: SelectOption | FlatSelectOption | AutoCompleteOption,
+  ) => {
     if (context.value.multiple) {
       const selectedValue = currentValue.value ?? []
       const optionIndex = selectedValue.indexOf(option.value)

+ 479 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/FieldAutoComplete.stories.ts

@@ -0,0 +1,479 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { Story } from '@storybook/vue3'
+import { FormKit } from '@formkit/vue'
+import { escapeRegExp } from 'lodash-es'
+import defaultArgTypes from '@stories/support/form/field/defaultArgTypes'
+import { FieldArgs } from '@stories/types/form'
+import { createMockClient } from 'mock-apollo-client'
+import { provideApolloClient } from '@vue/apollo-composable'
+import { AutocompleteSearchUserQuery } from '@shared/graphql/types'
+import { AutocompleteSearchUserDocument } from '@shared/graphql/queries/autocompleteSearch/user.api'
+
+export default {
+  title: 'Form/Field/AutoComplete',
+  component: FormKit,
+  argTypes: {
+    ...defaultArgTypes,
+    options: {
+      type: { name: 'array', required: true },
+      description: 'List of initial autocomplete options',
+      table: {
+        expanded: true,
+        type: {
+          summary: 'AutoCompleteOption[]',
+          detail: `{
+  value: string | number
+  label: string
+  labelPlaceholder?: string[]
+  heading?: string
+  headingPlaceholder?: string[]
+  disabled?: boolean
+  icon?: string
+}`,
+        },
+      },
+    },
+    action: {
+      description: 'Defines route for an optional action button in the dialog',
+    },
+    actionIcon: {
+      description: 'Defines optional icon for the action button in the dialog',
+    },
+    autoselect: {
+      description:
+        'Automatically selects last option when and only when option list length equals one',
+    },
+    clearable: {
+      description: 'Allows clearing of selected values',
+    },
+    debounceInterval: {
+      description:
+        'Defines interval for debouncing search input (default: 500)',
+    },
+    gqlQuery: {
+      description: 'Defines GraphQL query for the autocomplete search',
+    },
+    limit: {
+      description: 'Controls maximum number of results',
+    },
+    multiple: {
+      description: 'Allows multi selection',
+    },
+    noOptionsLabelTranslation: {
+      description: 'Skips translation of option labels',
+    },
+    optionIconComponent: {
+      type: { name: 'Component', required: false },
+      description:
+        'Controls which type of icon component will be used for options',
+    },
+    sorting: {
+      type: { name: 'string', required: false },
+      description: 'Sorts options by property',
+      table: {
+        type: {
+          summary: "'label' | 'value'",
+        },
+      },
+      options: [undefined, 'label', 'value'],
+      control: {
+        type: 'select',
+      },
+    },
+  },
+}
+
+const testOptions = [
+  {
+    value: 0,
+    label: 'Item A',
+    icon: 'gitlab-logo',
+    heading: 'autocomplete sample 1',
+  },
+  {
+    value: 1,
+    label: 'Item B',
+    icon: 'github-logo',
+    heading: 'autocomplete sample 2',
+  },
+  {
+    value: 2,
+    label: 'Ítem C',
+    icon: 'web',
+    heading: 'autocomplete sample 3',
+  },
+]
+
+const gqlQuery = `
+  query autocompleteSearchUser($query: String!, $limit: Int) {
+    autocompleteSearchUser(query: $query, limit: $limit) {
+      value
+      label
+      labelPlaceholder
+      disabled
+      icon
+    }
+  }
+`
+
+const mockQueryResult = (
+  query: string,
+  limit: number,
+): AutocompleteSearchUserQuery => {
+  const options = testOptions.map((option) => ({
+    ...option,
+    labelPlaceholder: null,
+    headingPlaceholder: null,
+    disabled: null,
+    __typename: 'AutocompleteEntry',
+  }))
+
+  const deaccent = (s: string) =>
+    s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+
+  // Trim and de-accent search keywords and compile them as a case-insensitive regex.
+  //   Make sure to escape special regex characters!
+  const filterRegex = new RegExp(escapeRegExp(deaccent(query)), 'i')
+
+  // Search across options via their de-accented labels.
+  const filteredOptions = options.filter((option) =>
+    filterRegex.test(deaccent(option.label)),
+  ) as unknown as {
+    __typename?: 'AutocompleteEntry'
+    value: string
+    label: string
+    labelPlaceholder?: Array<string> | null
+    disabled?: boolean | null
+    icon?: string | null
+  }[]
+
+  return {
+    autocompleteSearchUser: filteredOptions.slice(0, limit ?? 25),
+  }
+}
+
+const mockClient = () => {
+  const mockApolloClient = createMockClient()
+
+  console.log('mockApolloClient', mockApolloClient)
+
+  mockApolloClient.setRequestHandler(
+    AutocompleteSearchUserDocument,
+    (variables) => {
+      console.log('VARIABLES', variables)
+      return Promise.resolve({
+        data: mockQueryResult(variables.query, variables.limit),
+      })
+    },
+  )
+
+  provideApolloClient(mockApolloClient)
+}
+
+const Template: Story<FieldArgs> = (args: FieldArgs) => ({
+  components: { FormKit },
+  setup() {
+    mockClient()
+    return { args }
+  },
+  template: '<FormKit type="autocomplete" v-bind="args"/>',
+})
+
+export const Default = Template.bind({})
+Default.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Auto Complete',
+  name: 'autocomplete',
+}
+
+export const InitialOptions = Template.bind({})
+InitialOptions.args = {
+  options: [
+    {
+      value: 0,
+      label: 'Item A',
+    },
+    {
+      value: 1,
+      label: 'Item B',
+    },
+    {
+      value: 2,
+      label: 'Item C',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Initial Options',
+  name: 'autocomplete_options',
+}
+
+export const DefaultValue = Template.bind({})
+DefaultValue.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Default Value',
+  name: 'autocomplete_default_value',
+  value: 0,
+}
+
+export const ClearableValue = Template.bind({})
+ClearableValue.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: true,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Clearable Value',
+  name: 'autocomplete_clearable',
+  value: 1,
+}
+
+export const QueryLimit = Template.bind({})
+QueryLimit.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: 1,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Query Limit',
+  name: 'autocomplete_limit',
+}
+
+export const DisabledState = Template.bind({})
+DisabledState.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Disabled State',
+  name: 'autocomplete_disabled',
+  value: 2,
+  disabled: true,
+}
+
+export const MultipleSelection = Template.bind({})
+MultipleSelection.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: true,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Multiple Selection',
+  name: 'autocomplete_multiple',
+  value: [0, 2],
+}
+
+export const OptionSorting = Template.bind({})
+OptionSorting.args = {
+  options: [
+    {
+      value: 1,
+      label: 'Item B',
+    },
+    {
+      value: 2,
+      label: 'Item C',
+    },
+    {
+      value: 0,
+      label: 'Item A',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: 'label',
+  label: 'Option Sorting',
+  name: 'autocomplete_sorting',
+}
+
+export const OptionTranslation = Template.bind({})
+OptionTranslation.args = {
+  options: [
+    {
+      value: 0,
+      label: 'Item A (%s)',
+      labelPlaceholder: ['1st'],
+    },
+    {
+      value: 1,
+      label: 'Item B (%s)',
+      labelPlaceholder: ['2nd'],
+    },
+    {
+      value: 2,
+      label: 'Item C (%s)',
+      labelPlaceholder: ['3rd'],
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Translation',
+  name: 'autocomplete_translation',
+}
+
+export const OptionAutoselect = Template.bind({})
+OptionAutoselect.args = {
+  options: [
+    {
+      value: 1,
+      label: 'The One',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: true,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Autoselect',
+  name: 'autocomplete_autoselect',
+}
+
+export const OptionDisabled = Template.bind({})
+OptionDisabled.args = {
+  options: [
+    {
+      value: 0,
+      label: 'Item A',
+    },
+    {
+      value: 1,
+      label: 'Item B',
+      disabled: true,
+    },
+    {
+      value: 2,
+      label: 'Item C',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Disabled',
+  name: 'autocomplete_disabled',
+}
+
+export const OptionIcon = Template.bind({})
+OptionIcon.args = {
+  options: [
+    {
+      value: 1,
+      label: 'GitLab',
+      icon: 'gitlab-logo',
+    },
+    {
+      value: 2,
+      label: 'GitHub',
+      icon: 'github-logo',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Icon',
+  name: 'autocomplete_icon',
+}
+
+export const AdditionalAction = Template.bind({})
+AdditionalAction.args = {
+  options: null,
+  action: '/tickets',
+  actionIcon: 'web',
+  autoselect: false,
+  clearable: false,
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Additional Action',
+  name: 'autocomplete_action',
+}

+ 141 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue

@@ -0,0 +1,141 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { markRaw, ref, toRef } from 'vue'
+import { i18n } from '@shared/i18n'
+import { useDialog } from '@shared/composables/useDialog'
+import useLocaleStore from '@shared/stores/locale'
+import useValue from '../../composables/useValue'
+import useSelectOptions from '../../composables/useSelectOptions'
+import useSelectAutoselect from '../../composables/useSelectAutoselect'
+import type { FormFieldContext } from '../../types/field'
+import type { AutoCompleteOption, AutoCompleteProps } from './types'
+
+interface Props {
+  context: FormFieldContext<
+    AutoCompleteProps & {
+      gqlQuery: string
+    }
+  >
+}
+
+const props = defineProps<Props>()
+
+const { hasValue, valueContainer, clearValue } = useValue(
+  toRef(props, 'context'),
+)
+
+const localOptions = ref(props.context.options || [])
+
+const nameDialog = `field-auto-complete-${props.context.id}`
+
+const dialog = useDialog({
+  name: nameDialog,
+  prefetch: true,
+  component: () => import('./FieldAutoCompleteInputDialog.vue'),
+})
+
+const openModal = () => {
+  return dialog.open({
+    context: toRef(props, 'context'),
+    name: nameDialog,
+    options: localOptions,
+    optionIconComponent: props.context.optionIconComponent
+      ? markRaw(props.context.optionIconComponent)
+      : null,
+    onUpdateOptions: (options: AutoCompleteOption[]) => {
+      localOptions.value = options
+    },
+  })
+}
+
+const { sortedOptions, getSelectedOptionIcon, getSelectedOptionLabel } =
+  useSelectOptions(localOptions, toRef(props, 'context'))
+
+const toggleDialog = async (isVisible: boolean) => {
+  if (isVisible) {
+    await openModal()
+    return
+  }
+
+  await dialog.close()
+}
+
+useSelectAutoselect(sortedOptions, toRef(props, 'context'))
+
+const locale = useLocaleStore()
+</script>
+
+<template>
+  <div
+    :class="{
+      [context.classes.input]: true,
+    }"
+    class="flex h-auto min-h-[3.5rem] rounded-none bg-transparent focus-within:bg-blue-highlight focus-within:pt-0 formkit-populated:pt-0"
+    data-test-id="field-autocomplete"
+  >
+    <output
+      :id="context.id"
+      :name="context.node.name"
+      class="flex grow cursor-pointer items-center focus:outline-none formkit-disabled:pointer-events-none ltr:pr-3 rtl:pl-3"
+      :aria-disabled="context.disabled"
+      :aria-label="i18n.t('Select…')"
+      :tabindex="context.disabled ? '-1' : '0'"
+      v-bind="context.attrs"
+      role="list"
+      @click="toggleDialog(true)"
+      @keypress.space="toggleDialog(true)"
+      @blur="context.handlers.blur"
+    >
+      <div class="flex grow translate-y-2 flex-wrap gap-1">
+        <template v-if="hasValue">
+          <div
+            v-for="selectedValue in valueContainer"
+            :key="selectedValue"
+            class="flex items-center text-base leading-[19px] after:content-[','] last:after:content-none"
+            role="listitem"
+          >
+            <CommonIcon
+              v-if="getSelectedOptionIcon(selectedValue)"
+              :name="getSelectedOptionIcon(selectedValue)"
+              :fixed-size="{ width: 12, height: 12 }"
+              class="mr-1"
+            />
+            {{ getSelectedOptionLabel(selectedValue) || selectedValue }}
+          </div>
+        </template>
+      </div>
+      <CommonIcon
+        v-if="context.clearable && hasValue && !context.disabled"
+        :aria-label="i18n.t('Clear Selection')"
+        :fixed-size="{ width: 16, height: 16 }"
+        class="mr-2 shrink-0"
+        name="close-small"
+        role="button"
+        tabindex="0"
+        @click.stop="clearValue"
+        @keypress.space.prevent.stop="clearValue"
+      />
+      <CommonIcon
+        :fixed-size="{ width: 24, height: 24 }"
+        class="shrink-0"
+        :name="`chevron-${locale.localeData?.dir === 'rtl' ? 'left' : 'right'}`"
+        decorative
+      />
+    </output>
+  </div>
+</template>
+
+<style lang="scss">
+.field-autocomplete {
+  &.floating-input:focus-within:not([data-populated]) {
+    label {
+      @apply translate-y-0 translate-x-0 scale-100 opacity-100;
+    }
+  }
+
+  .formkit-label {
+    @apply py-4;
+  }
+}
+</style>

+ 341 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInputDialog.vue

@@ -0,0 +1,341 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import {
+  computed,
+  ConcreteComponent,
+  nextTick,
+  onMounted,
+  Ref,
+  ref,
+  toRef,
+  watch,
+} from 'vue'
+import { useRouter } from 'vue-router'
+import { cloneDeep } from 'lodash-es'
+import { refDebounced } from '@vueuse/core'
+import { useLazyQuery } from '@vue/apollo-composable'
+import gql from 'graphql-tag'
+import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
+import CommonInputSearch from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
+import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
+import { QueryHandler } from '@shared/server/apollo/handler'
+import { closeDialog } from '@shared/composables/useDialog'
+import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
+import useSelectOptions from '../../composables/useSelectOptions'
+import useValue from '../../composables/useValue'
+import type { FormFieldContext } from '../../types/field'
+import type { AutoCompleteProps, AutoCompleteOption } from './types'
+
+const props = defineProps<{
+  context: FormFieldContext<
+    AutoCompleteProps & {
+      gqlQuery: string
+    }
+  >
+  name: string
+  options: AutoCompleteOption[]
+  optionIconComponent: ConcreteComponent
+}>()
+
+const { isCurrentValue } = useValue(toRef(props, 'context'))
+
+const emit = defineEmits<{
+  (e: 'updateOptions', options: AutoCompleteOption[]): void
+}>()
+
+const { sortedOptions, selectOption, advanceDialogFocus } = useSelectOptions(
+  toRef(props, 'options'),
+  toRef(props, 'context'),
+)
+
+let areLocalOptionsReplaced = false
+
+const replacementLocalOptions: Ref<AutoCompleteOption[]> = ref(
+  cloneDeep(props.options),
+)
+
+const filter = ref('')
+
+const clearFilter = () => {
+  filter.value = ''
+}
+
+const filterInput = ref(null)
+
+const focusFirstTarget = () => {
+  const filterInputElement = filterInput.value as null | HTMLElement
+  if (filterInputElement) filterInputElement.focus()
+}
+
+onMounted(() => {
+  if (areLocalOptionsReplaced) {
+    replacementLocalOptions.value = [...props.options]
+  }
+
+  nextTick(() => focusFirstTarget())
+})
+
+const close = () => {
+  if (props.context.multiple) {
+    emit('updateOptions', [...replacementLocalOptions.value])
+    replacementLocalOptions.value = []
+    areLocalOptionsReplaced = true
+  }
+
+  closeDialog(props.name)
+  clearFilter()
+}
+
+const trimmedFilter = computed(() => filter.value.trim())
+
+const debouncedFilter = refDebounced(
+  trimmedFilter,
+  props.context.debounceInterval ?? 500,
+)
+
+const AutocompleteSearchDocument = gql`
+  ${props.context.gqlQuery}
+`
+
+// TODO: Check the cache policy for this query, because already triggered searches are re-used from the cache and if
+//   the source was changed in the meantime, the result will not be updated. It's unclear if there is a subscription in
+//   place to update the result on any changes.
+const autocompleteQueryHandler = new QueryHandler(
+  useLazyQuery(AutocompleteSearchDocument, () => ({
+    query: debouncedFilter.value,
+    limit: props.context.limit,
+  })),
+)
+
+watch(
+  () => debouncedFilter.value,
+  (newValue) => {
+    if (!newValue.length) return
+    autocompleteQueryHandler.load()
+  },
+)
+
+const autocompleteQueryResultKey = (
+  (AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
+    .selectionSet.selections[0] as SelectionNode & { name: NameNode }
+).name.value
+
+const autocompleteQueryResultOptions = computed(
+  () =>
+    autocompleteQueryHandler.result().value?.[
+      autocompleteQueryResultKey
+    ] as unknown as AutoCompleteOption[],
+)
+
+const autocompleteOptions = computed(
+  () => autocompleteQueryResultOptions.value || [],
+)
+
+const { sortedOptions: sortedAutocompleteOptions } = useSelectOptions(
+  autocompleteOptions,
+  toRef(props, 'context'),
+)
+
+const select = (option: AutoCompleteOption) => {
+  selectOption(option)
+
+  if (props.context.multiple) {
+    // If the current value contains the selected option, make sure it's added to the replacement list
+    //   if it's not already there.
+    if (
+      isCurrentValue(option.value) &&
+      !replacementLocalOptions.value.some(
+        (replacementLocalOption) =>
+          replacementLocalOption.value === option.value,
+      )
+    ) {
+      replacementLocalOptions.value.push(option)
+    }
+
+    // Remove any extra options from the replacement list.
+    replacementLocalOptions.value = replacementLocalOptions.value.filter(
+      (replacementLocalOption) => isCurrentValue(replacementLocalOption.value),
+    )
+
+    // Sort the replacement list according to the original order.
+    replacementLocalOptions.value.sort(
+      (a, b) =>
+        sortedOptions.value.findIndex((option) => option.value === a.value) -
+        sortedOptions.value.findIndex((option) => option.value === b.value),
+    )
+
+    return
+  }
+
+  emit('updateOptions', [option])
+
+  close()
+}
+
+const OptionIconComponent =
+  props.optionIconComponent ?? FieldAutoCompleteOptionIcon
+
+const router = useRouter()
+
+const executeAction = () => {
+  if (!props.context.action) return
+  router.push(props.context.action)
+}
+</script>
+
+<template>
+  <CommonDialog
+    :name="name"
+    :label="$t(context.label)"
+    :listeners="{ done: { onKeydown: advanceDialogFocus } }"
+    @close="close"
+  >
+    <template #before-label>
+      <div
+        v-if="context.action"
+        class="absolute top-0 left-0 bottom-0 flex items-center pl-4"
+      >
+        <div
+          class="grow cursor-pointer text-white"
+          tabindex="0"
+          role="button"
+          @click="close"
+          @keypress.space="close"
+          @keydown="advanceDialogFocus"
+        >
+          {{ i18n.t('Cancel') }}
+        </div>
+      </div>
+    </template>
+    <template #after-label>
+      <div class="absolute top-0 right-0 bottom-0 flex items-center pr-4">
+        <CommonIcon
+          v-if="context.action"
+          :name="context.actionIcon ? context.actionIcon : 'external'"
+          :fixed-size="{ width: 24, height: 24 }"
+          class="cursor-pointer text-white"
+          tabindex="0"
+          role="button"
+          @click="executeAction"
+          @keypress.space="executeAction"
+          @keydown="advanceDialogFocus"
+        />
+        <div
+          v-else
+          class="grow cursor-pointer text-blue"
+          tabindex="0"
+          role="button"
+          @click="close()"
+          @keypress.space="close()"
+          @keydown="advanceDialogFocus"
+        >
+          {{ i18n.t('Done') }}
+        </div>
+      </div>
+    </template>
+    <div class="w-full p-4">
+      <CommonInputSearch ref="filterInput" v-model="filter" />
+    </div>
+    <div
+      class="flex grow flex-col items-start self-stretch overflow-y-auto"
+      role="listbox"
+    >
+      <div
+        v-for="(option, index) in filter
+          ? sortedAutocompleteOptions
+          : sortedOptions"
+        :key="option.value"
+        :class="{
+          'pointer-events-none': option.disabled,
+        }"
+        :tabindex="option.disabled ? '-1' : '0'"
+        :aria-selected="isCurrentValue(option.value)"
+        class="relative flex h-[58px] cursor-pointer items-center self-stretch px-6 py-5 text-base leading-[19px] text-white focus:bg-blue-highlight focus:outline-none"
+        role="option"
+        @click="select(option as AutoCompleteOption)"
+        @keypress.space="select(option as AutoCompleteOption)"
+        @keydown="advanceDialogFocus($event, option)"
+      >
+        <div
+          v-if="index !== 0"
+          :class="{
+            'left-4': !context.multiple && !option.icon,
+            'left-[60px]': context.multiple && !option.icon,
+            'left-[72px]': !context.multiple && option.icon,
+            'left-[108px]': context.multiple && option.icon,
+          }"
+          class="absolute right-4 top-0 h-0 border-t border-white/10"
+        />
+        <CommonIcon
+          v-if="context.multiple"
+          :class="{
+            '!text-white': isCurrentValue(option.value),
+            'opacity-30': option.disabled,
+          }"
+          :fixed-size="{ width: 24, height: 24 }"
+          :name="isCurrentValue(option.value) ? 'checked-yes' : 'checked-no'"
+          class="mr-3 text-white/50"
+        />
+        <OptionIconComponent :option="option" />
+        <div
+          v-if="(option as AutoCompleteOption).heading"
+          class="flex grow flex-col"
+        >
+          <span
+            :class="{
+              'opacity-30': option.disabled,
+            }"
+            class="grow text-sm text-gray-100"
+          >
+            {{ (option as AutoCompleteOption).heading }}
+          </span>
+          <span
+            :class="{
+              'opacity-30': option.disabled,
+            }"
+            class="grow text-lg font-semibold leading-[22px]"
+          >
+            {{ option.label || option.value }}
+          </span>
+        </div>
+        <span
+          v-else
+          :class="{
+            'font-semibold !text-white': isCurrentValue(option.value),
+            'opacity-30': option.disabled,
+          }"
+          class="grow text-white/80"
+        >
+          {{ option.label || option.value }}
+        </span>
+        <CommonIcon
+          v-if="!context.multiple && isCurrentValue(option.value)"
+          :class="{
+            'opacity-30': option.disabled,
+          }"
+          :fixed-size="{ width: 16, height: 16 }"
+          name="check"
+        />
+      </div>
+      <div
+        v-if="
+          debouncedFilter &&
+          autocompleteQueryResultOptions &&
+          !autocompleteOptions.length
+        "
+        class="relative flex h-[58px] items-center justify-center self-stretch py-5 px-4 text-base leading-[19px] text-white/50"
+        role="alert"
+      >
+        {{ i18n.t('No results found') }}
+      </div>
+      <div
+        v-else-if="!debouncedFilter && !options.length"
+        class="relative flex h-[58px] items-center justify-center self-stretch py-5 px-4 text-base leading-[19px] text-white/50"
+        role="alert"
+      >
+        {{ i18n.t('Start typing to search…') }}
+      </div>
+    </div>
+  </CommonDialog>
+</template>

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