Browse Source

Feature: Mobile - Add custom recipient form field

Dusan Vuckovic 2 years ago
parent
commit
e68e27b60b

+ 6 - 0
app/frontend/apps/mobile/form/theme/global/getCoreClasses.ts

@@ -108,6 +108,12 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
         classes.organization && classes.organization.outer
       } field-organization`,
     }),
+    recipient: addFloatingLabel({
+      ...(classes.recipient || {}),
+      outer: `${
+        classes.recipient && classes.recipient.outer
+      } field-autocomplete`,
+    }),
     button: addButtonVariants(classes.button),
     submit: addButtonVariants(classes.submit),
   }

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

@@ -103,7 +103,6 @@ const linkSchemaRaw = [
     name: 'autocomplete',
     label: 'AutoComplete',
     props: {
-      // options: [{ label: 'Label', value: 1 }],
       sorting: 'label',
       link: '/tickets',
       action: '/tickets',
@@ -120,6 +119,26 @@ query autocompleteSearchUser($query: String!, $limit: Int) {
     icon
   }
 }
+`,
+    },
+  },
+  {
+    type: 'recipient',
+    name: 'recipient',
+    label: 'Recipient',
+    props: {
+      gqlQuery: `
+query autocompleteSearchUser($query: String!, $limit: Int) {
+  autocompleteSearchUser(query: $query, limit: $limit) {
+    value
+    label
+    labelPlaceholder
+    heading
+    headingPlaceholder
+    disabled
+    icon
+  }
+}
 `,
     },
   },

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

@@ -40,6 +40,9 @@ export default {
     actionIcon: {
       description: 'Defines optional icon for the action button in the dialog',
     },
+    allowUnknownValues: {
+      description: 'Allows selection of unknown values entered as search input',
+    },
     autoselect: {
       description:
         'Automatically selects last option when and only when option list length equals one',
@@ -51,6 +54,9 @@ export default {
       description:
         'Defines interval for debouncing search input (default: 500)',
     },
+    filterInputValidation: {
+      description: 'Defines FormKit validation rule for search input',
+    },
     gqlQuery: {
       description: 'Defines GraphQL query for the autocomplete search',
     },
@@ -474,3 +480,35 @@ AdditionalAction.args = {
   label: 'Additional Action',
   name: 'autocomplete_action',
 }
+
+export const UnknownValues = Template.bind({})
+UnknownValues.args = {
+  options: [
+    {
+      value: 0,
+      label: 'Item A',
+    },
+    {
+      value: 1,
+      label: 'Item B',
+    },
+    {
+      value: 2,
+      label: 'Item C',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  allowUnknownValues: true,
+  autoselect: false,
+  clearable: false,
+  filterInputValidation: 'starts_with:Item',
+  gqlQuery,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Unknown Values',
+  name: 'autocomplete_unknown_values',
+}

+ 52 - 11
app/frontend/shared/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInputDialog.vue

@@ -9,10 +9,10 @@ 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 type { FormKitNode } from '@formkit/core'
 import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
 import useSelectOptions from '../../composables/useSelectOptions'
 import useValue from '../../composables/useValue'
@@ -50,15 +50,22 @@ const replacementLocalOptions: Ref<AutoCompleteOption[]> = ref(
 
 const filter = ref('')
 
-const clearFilter = () => {
-  filter.value = ''
-}
-
 const filterInput = ref(null)
 
 const focusFirstTarget = () => {
-  const filterInputElement = filterInput.value as null | HTMLElement
-  if (filterInputElement) filterInputElement.focus()
+  const filterInputFormKit = filterInput.value as null | { node: FormKitNode }
+  if (!filterInputFormKit) return
+
+  const filterInputElement = document.getElementById(
+    filterInputFormKit.node.context?.id as string,
+  )
+  if (!filterInputElement) return
+
+  filterInputElement.focus()
+}
+
+const clearFilter = () => {
+  filter.value = ''
 }
 
 onMounted(() => {
@@ -121,9 +128,25 @@ const autocompleteQueryResultOptions = computed(
     ] as unknown as AutoCompleteOption[],
 )
 
-const autocompleteOptions = computed(
-  () => autocompleteQueryResultOptions.value || [],
-)
+const autocompleteOptions = computed(() => {
+  const result = cloneDeep(autocompleteQueryResultOptions.value) || []
+
+  const filterInputFormKit = filterInput.value as null | { node: FormKitNode }
+
+  if (
+    props.context.allowUnknownValues &&
+    filterInputFormKit &&
+    filterInputFormKit.node.context?.state.complete &&
+    !result.some((option) => option.value === trimmedFilter.value)
+  ) {
+    result.unshift({
+      value: trimmedFilter.value,
+      label: trimmedFilter.value,
+    })
+  }
+
+  return result
+})
 
 const { sortedOptions: sortedAutocompleteOptions } = useSelectOptions(
   autocompleteOptions,
@@ -182,6 +205,7 @@ const executeAction = () => {
     :name="name"
     :label="context.label"
     :listeners="{ done: { onKeydown: advanceDialogFocus } }"
+    class="field-autocomplete-dialog"
     @close="close"
   >
     <template #before-label>
@@ -226,7 +250,16 @@ const executeAction = () => {
       </div>
     </template>
     <div class="w-full p-4">
-      <CommonInputSearch ref="filterInput" v-model="filter" />
+      <FormKit
+        ref="filterInput"
+        v-model="filter"
+        :delay="context.node.props.delay"
+        :placeholder="context.filterInputPlaceholder"
+        :validation="context.filterInputValidation"
+        type="search"
+        validation-visibility="live"
+        role="searchbox"
+      />
     </div>
     <div
       class="flex grow flex-col items-start self-stretch overflow-y-auto"
@@ -330,3 +363,11 @@ const executeAction = () => {
     </div>
   </CommonDialog>
 </template>
+
+<style lang="scss">
+.field-autocomplete-dialog {
+  .formkit-wrapper {
+    @apply px-0;
+  }
+}
+</style>

+ 77 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/__tests__/FieldAutoComplete.spec.ts

@@ -672,6 +672,83 @@ describe('Form - Field - AutoComplete - Features', () => {
 
     expect(wrapper.getByIconName('web')).toBeInTheDocument()
   })
+
+  it('supports selection of unknown values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        allowUnknownValues: true,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    await wrapper.events.type(filterElement, 'Item D')
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('Item D')
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe('Item D')
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('Item D')
+  })
+
+  it('supports validation of filter input', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        allowUnknownValues: true,
+        debounceInterval: 0,
+        filterInputValidation: 'starts_with:#',
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    await wrapper.events.type(filterElement, 'foo')
+
+    expect(
+      wrapper.queryByText(`This field doesn't start with "#".`),
+    ).toBeInTheDocument()
+
+    expect(wrapper.queryByText('No results found')).toBeInTheDocument()
+
+    await wrapper.events.clear(filterElement)
+
+    await wrapper.events.type(filterElement, '#foo')
+
+    expect(
+      wrapper.queryByText(`This field doesn't start with "#".`),
+    ).not.toBeInTheDocument()
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('#foo')
+  })
 })
 
 describe('Form - Field - AutoComplete - Accessibility', () => {

+ 3 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/index.ts

@@ -7,9 +7,12 @@ import FieldAutoCompleteInput from './FieldAutoCompleteInput.vue'
 export const autoCompleteProps = [
   'action',
   'actionIcon',
+  'allowUnknownValues',
   'autoselect',
   'clearable',
   'debounceInterval',
+  'filterInputPlaceholder',
+  'filterInputValidation',
   'limit',
   'multiple',
   'noOptionsLabelTranslation',

+ 3 - 0
app/frontend/shared/components/Form/fields/FieldAutoComplete/types.ts

@@ -18,10 +18,13 @@ export type AutoCompleteOption = {
 export type AutoCompleteProps = FormFieldContext<{
   action?: RouteLocationRaw
   actionIcon?: string
+  allowUnknownValues?: boolean
   autoselect?: boolean
   clearable?: boolean
   debounceInterval: number
   disabled?: boolean
+  filterInputPlaceholder?: string
+  filterInputValidation?: string
   limit?: number
   multiple?: boolean
   noOptionsLabelTranslation?: boolean

+ 468 - 0
app/frontend/shared/components/Form/fields/FieldRecipient/FieldRecipient.stories.ts

@@ -0,0 +1,468 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import type { Story } from '@storybook/vue3'
+import { FormKit } from '@formkit/vue'
+import { escapeRegExp } from 'lodash-es'
+import gql from 'graphql-tag'
+import defaultArgTypes from '@stories/support/form/field/defaultArgTypes'
+import type { FieldArgs } from '@stories/types/form'
+import { createMockClient } from 'mock-apollo-client'
+import { provideApolloClient } from '@vue/apollo-composable'
+import type { AutoCompleteOption } from '../FieldAutoComplete'
+
+export default {
+  title: 'Form/Field/Recipient',
+  component: FormKit,
+  argTypes: {
+    ...defaultArgTypes,
+    options: {
+      type: { name: 'array', required: true },
+      description: 'List of initial recipients',
+      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 recipient 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: AutoCompleteOption[] = [
+  {
+    value: 'baz@bar.tld',
+    label: 'Baz',
+    heading: 'baz@bar.tld',
+  },
+  {
+    value: 'qux@bar.tld',
+    label: 'Qux',
+    heading: 'qux@bar.tld',
+  },
+  {
+    value: 'corge@bar.tld',
+    label: 'Corge',
+    heading: 'corge@bar.tld',
+  },
+]
+
+const AutocompleteSearchRecipientDocument = gql`
+  query autocompleteSearchRecipient($query: String!, $limit: Int) {
+    autocompleteSearchRecipient(query: $query, limit: $limit) {
+      value
+      label
+      labelPlaceholder
+      heading
+      headingPlaceholder
+      disabled
+      icon
+    }
+  }
+`
+
+type AutocompleteSearchRecipientQuery = {
+  __typename?: 'Queries'
+  autocompleteSearchRecipient: Array<{
+    __typename?: 'AutocompleteEntry'
+    value: string
+    label: string
+    labelPlaceholder?: Array<string> | null
+    heading?: string | null
+    headingPlaceholder?: Array<string> | null
+    disabled?: boolean | null
+    icon?: string | null
+  }>
+}
+
+const mockQueryResult = (
+  query: string,
+  limit: number,
+): AutocompleteSearchRecipientQuery => {
+  const options = testOptions.map((option) => ({
+    ...option,
+    labelPlaceholder: null,
+    headingPlaceholder: null,
+    disabled: null,
+    icon: 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)) ||
+      filterRegex.test(deaccent(option.heading as string)),
+  ) as unknown as {
+    __typename?: 'AutocompleteEntry'
+    value: string
+    label: string
+    labelPlaceholder?: Array<string> | null
+    disabled?: boolean | null
+    icon?: string | null
+  }[]
+
+  return {
+    autocompleteSearchRecipient: filteredOptions.slice(0, limit ?? 25),
+  }
+}
+
+const mockClient = () => {
+  const mockApolloClient = createMockClient()
+
+  mockApolloClient.setRequestHandler(
+    AutocompleteSearchRecipientDocument,
+    (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="recipient" v-bind="args"/>',
+})
+
+export const Default = Template.bind({})
+Default.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Recipient',
+  name: 'recipient',
+}
+
+export const InitialOptions = Template.bind({})
+InitialOptions.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Initial Options',
+  name: 'recipient_options',
+}
+
+export const DefaultValue = Template.bind({})
+DefaultValue.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Default Value',
+  name: 'recipient_default_value',
+  value: 'corge@bar.tld',
+}
+
+export const ClearableValue = Template.bind({})
+ClearableValue.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: true,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Clearable Value',
+  name: 'recipient_clearable',
+  value: 'qux@bar.tld',
+}
+
+export const QueryLimit = Template.bind({})
+QueryLimit.args = {
+  options: null,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: 1,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Query Limit',
+  name: 'recipient_limit',
+}
+
+export const DisabledState = Template.bind({})
+DisabledState.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Disabled State',
+  name: 'recipient_disabled',
+  value: 'baz@bar.tld',
+  disabled: true,
+}
+
+export const MultipleSelection = Template.bind({})
+MultipleSelection.args = {
+  options: testOptions,
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: true,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Multiple Selection',
+  name: 'recipient_multiple',
+  value: ['baz@bar.tld', 'corge@bar.tld'],
+}
+
+export const OptionSorting = Template.bind({})
+OptionSorting.args = {
+  options: testOptions.reverse(),
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: 'label',
+  label: 'Option Sorting',
+  name: 'recipient_sorting',
+}
+
+export const OptionTranslation = Template.bind({})
+OptionTranslation.args = {
+  options: [
+    {
+      value: 'baz@bar.tld',
+      label: 'Baz (%s)',
+      labelPlaceholder: ['1st'],
+      heading: 'baz@bar.tld',
+    },
+    {
+      value: 'qux@bar.tld',
+      label: 'Qux (%s)',
+      labelPlaceholder: ['2nd'],
+      heading: 'qux@bar.tld',
+    },
+    {
+      value: 'corge@bar.tld',
+      label: 'Corge (%s)',
+      labelPlaceholder: ['3rd'],
+      heading: 'corge@bar.tld',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Translation',
+  name: 'recipient_translation',
+}
+
+export const OptionAutoselect = Template.bind({})
+OptionAutoselect.args = {
+  options: [
+    {
+      value: 'foo@bar.tld',
+      label: 'Foo',
+      heading: 'foo@bar.tld',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: true,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Autoselect',
+  name: 'recipient_autoselect',
+}
+
+export const OptionDisabled = Template.bind({})
+OptionDisabled.args = {
+  options: [
+    {
+      value: 'baz@bar.tld',
+      label: 'Baz',
+      heading: 'baz@bar.tld',
+    },
+    {
+      value: 'qux@bar.tld',
+      label: 'Qux',
+      heading: 'qux@bar.tld',
+      disabled: true,
+    },
+    {
+      value: 'corge@bar.tld',
+      label: 'Corge',
+      heading: 'corge@bar.tld',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Disabled',
+  name: 'recipient_disabled',
+}
+
+export const OptionIcon = Template.bind({})
+OptionIcon.args = {
+  options: [
+    {
+      value: 'baz@bar.tld',
+      label: 'Baz',
+      heading: 'baz@bar.tld',
+      icon: 'email',
+    },
+    {
+      value: 'qux@bar.tld',
+      label: 'Qux',
+      heading: 'qux@bar.tld',
+      icon: 'email',
+    },
+    {
+      value: 'corge@bar.tld',
+      label: 'Corge',
+      heading: 'corge@bar.tld',
+      icon: 'email',
+    },
+  ],
+  action: null,
+  actionIcon: null,
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Option Icon',
+  name: 'recipient_icon',
+}
+
+export const AdditionalAction = Template.bind({})
+AdditionalAction.args = {
+  options: null,
+  action: '/tickets',
+  actionIcon: 'web',
+  autoselect: false,
+  clearable: false,
+  limit: null,
+  multiple: false,
+  noOptionsLabelTranslation: false,
+  size: null,
+  sorting: null,
+  label: 'Additional Action',
+  name: 'recipient_action',
+}

+ 206 - 0
app/frontend/shared/components/Form/fields/FieldRecipient/__tests__/FieldRecipient.spec.ts

@@ -0,0 +1,206 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { escapeRegExp } from 'lodash-es'
+import { waitFor } from '@testing-library/vue'
+import { FormKit } from '@formkit/vue'
+import gql from 'graphql-tag'
+import { renderComponent } from '@tests/support/components'
+import { createMockClient } from 'mock-apollo-client'
+import { provideApolloClient } from '@vue/apollo-composable'
+import type { AutoCompleteOption } from '../../FieldAutoComplete/types'
+
+const testOptions: AutoCompleteOption[] = [
+  {
+    value: 'baz@bar.tld',
+    label: 'Baz',
+    heading: 'baz@bar.tld',
+  },
+  {
+    value: 'qux@bar.tld',
+    label: 'Qux',
+    heading: 'qux@bar.tld',
+  },
+  {
+    value: 'corge@bar.tld',
+    label: 'Corge',
+    heading: 'corge@bar.tld',
+  },
+]
+
+const AutocompleteSearchRecipientDocument = gql`
+  query autocompleteSearchRecipient($query: String!, $limit: Int) {
+    autocompleteSearchRecipient(query: $query, limit: $limit) {
+      value
+      label
+      labelPlaceholder
+      heading
+      headingPlaceholder
+      disabled
+      icon
+    }
+  }
+`
+
+type AutocompleteSearchRecipientQuery = {
+  __typename?: 'Queries'
+  autocompleteSearchRecipient: Array<{
+    __typename?: 'AutocompleteEntry'
+    value: string
+    label: string
+    labelPlaceholder?: Array<string> | null
+    heading?: string | null
+    headingPlaceholder?: Array<string> | null
+    disabled?: boolean | null
+    icon?: string | null
+  }>
+}
+
+const mockQueryResult = (
+  query: string,
+  limit: number,
+): AutocompleteSearchRecipientQuery => {
+  const options = testOptions.map((option) => ({
+    ...option,
+    labelPlaceholder: null,
+    headingPlaceholder: null,
+    disabled: null,
+    icon: 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)) ||
+      filterRegex.test(deaccent(option.heading!)),
+  ) as unknown as {
+    __typename?: 'AutocompleteEntry'
+    value: string
+    label: string
+    labelPlaceholder?: Array<string> | null
+    heading?: string | null
+    headingPlaceholder?: Array<string> | null
+    disabled?: boolean | null
+    icon?: string | null
+  }[]
+
+  return {
+    autocompleteSearchRecipient: filteredOptions.slice(0, limit ?? 25),
+  }
+}
+
+const mockClient = () => {
+  const mockApolloClient = createMockClient()
+
+  mockApolloClient.setRequestHandler(
+    AutocompleteSearchRecipientDocument,
+    (variables) => {
+      return Promise.resolve({
+        data: mockQueryResult(variables.query, variables.limit),
+      })
+    },
+  )
+
+  provideApolloClient(mockApolloClient)
+}
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+  router: true,
+  dialog: true,
+  store: true,
+}
+
+const testProps = {
+  type: 'recipient',
+}
+
+beforeAll(async () => {
+  // So we don't need to wait until it loads inside test.
+  await import('../../FieldAutoComplete/FieldAutoCompleteInputDialog.vue')
+})
+
+// We include only some query-related test cases, as the actual autocomplete component has its own unit test.
+describe('Form - Field - Recipient - Features', () => {
+  mockClient()
+
+  it('supports selection of unknown values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    await wrapper.events.type(filterElement, 'foo@bar.tld')
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('foo@bar.tld')
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe('foo@bar.tld')
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('foo@bar.tld')
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('foo@bar.tld')
+  })
+
+  it('supports validation of filter input', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByRole('list'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    await wrapper.events.type(filterElement, 'bar')
+
+    expect(
+      wrapper.queryByText('Please enter a valid email address.'),
+    ).toBeInTheDocument()
+
+    await wrapper.events.clear(filterElement)
+
+    await wrapper.events.type(filterElement, 'foo@bar.tld')
+
+    expect(
+      wrapper.queryByText('Please enter a valid email address.'),
+    ).not.toBeInTheDocument()
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('foo@bar.tld')
+  })
+})

+ 42 - 0
app/frontend/shared/components/Form/fields/FieldRecipient/index.ts

@@ -0,0 +1,42 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import type { FormKitNode } from '@formkit/core'
+import createInput from '@shared/form/core/createInput'
+import addLink from '@shared/form/features/addLink'
+import FieldAutoCompleteInput from '../FieldAutoComplete/FieldAutoCompleteInput.vue'
+import { autoCompleteProps } from '../FieldAutoComplete'
+
+const setAutoCompleteBehavior = (node: FormKitNode) => {
+  const { props } = node
+
+  // Allow selection of unknown values, but only if they pass email validation.
+  //   Include helpful hint in the search input field.
+  props.allowUnknownValues = true
+  props.filterInputPlaceholder = __('Search or enter email address...')
+  props.filterInputValidation = 'email'
+
+  node.addProps(['gqlQuery'])
+
+  props.gqlQuery = `
+  query autocompleteSearchRecipient($query: String!, $limit: Int) {
+    autocompleteSearchRecipient(query: $query, limit: $limit) {
+      value
+      label
+      labelPlaceholder
+      heading
+      headingPlaceholder
+      disabled
+      icon
+    }
+  }
+  `
+}
+
+const fieldDefinition = createInput(FieldAutoCompleteInput, autoCompleteProps, {
+  features: [addLink, setAutoCompleteBehavior],
+})
+
+export default {
+  fieldType: 'recipient',
+  definition: fieldDefinition,
+}

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