Browse Source

Feature: Desktop view - Add form field organization.

Martin Gruner 9 months ago
parent
commit
12979be051

+ 21 - 0
app/frontend/apps/desktop/components/Form/fields/FieldOrganization/FieldOrganizationOptionIcon.vue

@@ -0,0 +1,21 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import CommonOrganizationAvatar from '#shared/components/CommonOrganizationAvatar/CommonOrganizationAvatar.vue'
+import type { AutoCompleteOrganizationOption } from '#shared/components/Form/fields/FieldOrganization/types'
+
+defineProps<{
+  option: AutoCompleteOrganizationOption
+}>()
+</script>
+
+<template>
+  <CommonOrganizationAvatar
+    v-if="option.organization"
+    :entity="option.organization"
+    :class="{
+      'opacity-30': option.disabled,
+    }"
+    size="xs"
+  />
+</template>

+ 24 - 0
app/frontend/apps/desktop/components/Form/fields/FieldOrganization/FieldOrganizationWrapper.vue

@@ -0,0 +1,24 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+<script setup lang="ts">
+import { markRaw } from 'vue'
+
+import useFormFieldOrganizationInitialOptionBuilder from '#shared/components/Form/fields/FieldOrganization/composables/useFieldOrganizationInitialOptionBuilder.ts'
+import { AutocompleteSearchOrganizationDocument } from '#shared/components/Form/fields/FieldOrganization/graphql/queries/autocompleteSearch/organization.api.ts'
+import type { AutocompleteOrganizationProps } from '#shared/components/Form/fields/FieldOrganization/types.ts'
+
+import FieldAutoCompleteInput from '../FieldAutoComplete/FieldAutoCompleteInput.vue'
+
+import FieldOrganizationOptionIcon from './FieldOrganizationOptionIcon.vue'
+
+const props = defineProps<AutocompleteOrganizationProps>()
+
+Object.assign(props.context, {
+  optionIconComponent: markRaw(FieldOrganizationOptionIcon),
+  initialOptionBuilder: useFormFieldOrganizationInitialOptionBuilder(),
+  gqlQuery: AutocompleteSearchOrganizationDocument,
+})
+</script>
+
+<template>
+  <FieldAutoCompleteInput :context="context" v-bind="$attrs" />
+</template>

+ 280 - 0
app/frontend/apps/desktop/components/Form/fields/FieldOrganization/__tests__/FieldOrganization.spec.ts

@@ -0,0 +1,280 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { getNode, type FormKitNode } from '@formkit/core'
+import { FormKit } from '@formkit/vue'
+import { waitFor } from '@testing-library/vue'
+
+import {
+  getByIconName,
+  queryByIconName,
+} from '#tests/support/components/iconQueries.ts'
+import { renderComponent } from '#tests/support/components/index.ts'
+import { nullableMock, waitForNextTick } from '#tests/support/utils.ts'
+
+import {
+  mockAutocompleteSearchOrganizationQuery,
+  waitForAutocompleteSearchOrganizationQueryCalls,
+} from '#shared/components/Form/fields/FieldOrganization/graphql/queries/autocompleteSearch/organization.mocks.ts'
+import type { AutocompleteSearchOrganizationEntry } from '#shared/graphql/types.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+const testOptions: AutocompleteSearchOrganizationEntry[] = [
+  {
+    __typename: 'AutocompleteSearchOrganizationEntry',
+    value: 1,
+    label: 'Zammad Foundation',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 1',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    organization: nullableMock({
+      id: convertToGraphQLId('Organization', 1),
+      internalId: 1,
+      name: 'Zammad Foundation',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      vip: true,
+      active: true,
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+  {
+    __typename: 'AutocompleteSearchOrganizationEntry',
+    value: 2,
+    label: 'Zammad Organization',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 2',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    organization: nullableMock({
+      id: convertToGraphQLId('Organization', 2),
+      internalId: 1,
+      name: 'Zammad Organization',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      vip: false,
+      active: false,
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+]
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+  router: true,
+  dialog: true,
+  store: true,
+}
+
+const testProps = {
+  type: 'organization',
+  label: 'Select…',
+}
+
+describe('Form - Field - Organization - Features', () => {
+  it('supports value prefill with existing entity object in root node', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        id: 'organization_id',
+        name: 'organization_id',
+        value: 123,
+        belongsToObjectField: 'organization',
+        // Add manually the "initialEntityObject" which is normally coming
+        // from the root node (for a single field root node === own node).
+        plugins: [
+          (node: FormKitNode) => {
+            node.context!.initialEntityObject = {
+              organization: {
+                name: 'Zammad Organization',
+                internalId: 123,
+              },
+            }
+          },
+        ],
+      },
+    })
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      'Zammad Organization',
+    )
+
+    // Reset the field with new value and before change the initial entity object.
+    const node = getNode('organization_id')!
+    node.context!.initialEntityObject = {
+      organization: {
+        internalId: 456,
+        name: 'Zammad Foundation',
+      },
+    }
+    node.reset('456')
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Zammad Foundation')
+  })
+})
+
+// We include only some query-related test cases, since the actual autocomplete component has its own unit test.
+describe('Form - Field - Organization - Query', () => {
+  it('fetches remote options via GraphQL query', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(await wrapper.findByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    expect(filterElement).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchOrganizationQuery({
+      autocompleteSearchOrganization: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchOrganizationQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+
+    // Organization with ID 1 should show the buildings icon (active).
+    expect(getByIconName(selectOptions[0], 'buildings')).toBeInTheDocument()
+
+    // Organization with ID 1 should have a silver crown (VIP).
+    expect(getByIconName(selectOptions[0], 'crown-silver')).toBeInTheDocument()
+
+    await wrapper.events.click(wrapper.getByLabelText('Clear Search'))
+
+    expect(filterElement).toHaveValue('')
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchOrganizationQuery({
+      autocompleteSearchOrganization: [testOptions[1]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[1].label)
+
+    await waitForAutocompleteSearchOrganizationQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[1].label)
+
+    // Organization with ID 2 should show the slashed buildings icon (inactive).
+    expect(
+      getByIconName(selectOptions[0], 'buildings-slash'),
+    ).toBeInTheDocument()
+
+    // Organization with ID 2 should not have a silver crown (not VIP).
+    expect(
+      queryByIconName(selectOptions[0], 'crown-silver'),
+    ).not.toBeInTheDocument()
+  })
+
+  it('replaces local options with selection', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(await wrapper.findByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchOrganizationQuery({
+      autocompleteSearchOrganization: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchOrganizationQueryCalls()
+
+    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(testOptions[0].value)
+
+    expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.getByIconName('check2')).toBeInTheDocument()
+  })
+
+  it('supports filtering out organizations of a specific user', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+        additionalQueryParams: {
+          customerId: '999',
+        },
+      },
+    })
+
+    await wrapper.events.click(await wrapper.findByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchOrganizationQuery({
+      autocompleteSearchOrganization: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, '*')
+
+    const calls = await waitForAutocompleteSearchOrganizationQueryCalls()
+
+    expect(calls.at(-1)?.variables).toEqual({
+      input: expect.objectContaining({
+        customerId: '999',
+      }),
+    })
+  })
+})

+ 20 - 0
app/frontend/apps/desktop/components/Form/fields/FieldOrganization/index.ts

@@ -0,0 +1,20 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import createInput from '#shared/form/core/createInput.ts'
+import addLink from '#shared/form/features/addLink.ts'
+import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
+
+import { autoCompleteProps } from '../FieldAutoComplete/index.ts'
+
+import FieldOrganizationWrapper from './FieldOrganizationWrapper.vue'
+
+const fieldDefinition = createInput(
+  FieldOrganizationWrapper,
+  autoCompleteProps,
+  { features: [addLink, formUpdaterTrigger()] },
+)
+
+export default {
+  fieldType: 'organization',
+  definition: fieldDefinition,
+}

+ 18 - 1
app/frontend/apps/desktop/pages/dashboard/views/Playground.vue

@@ -574,7 +574,7 @@ const formSchema = [
     props: {
       clearable: true,
       gqlQuery: gql`
-        query autocompleteSearchUser($input: AutocompleteSearchInput!) {
+        query autocompleteSearchUser($input: AutocompleteSearchUserInput!) {
           autocompleteSearchUser(input: $input) {
             value
             label
@@ -593,6 +593,23 @@ const formSchema = [
       clearable: true,
     },
   },
+  {
+    type: 'organization',
+    name: 'organization',
+    label: 'Organization',
+    props: {
+      clearable: true,
+      options: [
+        {
+          value: 1,
+          label: 'Zammad Foundation',
+          organization: {
+            active: true,
+          },
+        },
+      ],
+    },
+  },
   {
     name: 'date_0',
     label: 'Date',

+ 5 - 32
app/frontend/apps/mobile/components/Form/fields/FieldOrganization/FieldOrganizationWrapper.vue

@@ -1,15 +1,10 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 <script setup lang="ts">
-import { markRaw, defineAsyncComponent } from 'vue'
+import { defineAsyncComponent, markRaw } from 'vue'
 
-import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
-import type { AutoCompleteProps } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
+import useFormFieldOrganizationInitialOptionBuilder from '#shared/components/Form/fields/FieldOrganization/composables/useFieldOrganizationInitialOptionBuilder.ts'
 import { AutocompleteSearchOrganizationDocument } from '#shared/components/Form/fields/FieldOrganization/graphql/queries/autocompleteSearch/organization.api.ts'
-import type { AutoCompleteOrganizationOption } from '#shared/components/Form/fields/FieldOrganization/types.ts'
-import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
-import { getAutoCompleteOption } from '#shared/entities/organization/utils/getAutoCompleteOption.ts'
-import type { Organization } from '#shared/graphql/types.ts'
-import type { ObjectLike } from '#shared/types/utils.ts'
+import type { AutocompleteOrganizationProps } from '#shared/components/Form/fields/FieldOrganization/types.ts'
 
 import FieldOrganizationOptionIcon from './FieldOrganizationOptionIcon.vue'
 
@@ -20,33 +15,11 @@ const FieldAutoCompleteInput = defineAsyncComponent(
     ),
 )
 
-interface Props {
-  context: FormFieldContext<
-    AutoCompleteProps & {
-      options?: AutoCompleteOrganizationOption[]
-    }
-  >
-}
-
-const props = defineProps<Props>()
+const props = defineProps<AutocompleteOrganizationProps>()
 
 Object.assign(props.context, {
   optionIconComponent: markRaw(FieldOrganizationOptionIcon),
-  initialOptionBuilder: (
-    initialEntityObject: ObjectLike,
-    value: SelectValue,
-    context: Props['context'],
-  ) => {
-    if (!context.belongsToObjectField || !initialEntityObject) return null
-
-    const belongsToObject = initialEntityObject[
-      context.belongsToObjectField
-    ] as Organization
-
-    if (!belongsToObject) return null
-
-    return getAutoCompleteOption(belongsToObject)
-  },
+  initialOptionBuilder: useFormFieldOrganizationInitialOptionBuilder(),
   gqlQuery: AutocompleteSearchOrganizationDocument,
 })
 </script>

+ 1 - 1
app/frontend/apps/mobile/entities/organization/graphql/mutations/update.api.ts

@@ -1,7 +1,7 @@
 import * as Types from '#shared/graphql/types.ts';
 
 import gql from 'graphql-tag';
-import { OrganizationAttributesFragmentDoc } from '../fragments/organizationAttributes.api';
+import { OrganizationAttributesFragmentDoc } from '../../../../../../shared/entities/organization/graphql/organizationAttributes.api';
 import { ErrorsFragmentDoc } from '../../../../../../shared/graphql/fragments/errors.api';
 import * as VueApolloComposable from '@vue/apollo-composable';
 import * as VueCompositionApi from 'vue';

+ 1 - 1
app/frontend/apps/mobile/entities/organization/graphql/queries/organization.api.ts

@@ -1,7 +1,7 @@
 import * as Types from '#shared/graphql/types.ts';
 
 import gql from 'graphql-tag';
-import { OrganizationAttributesFragmentDoc } from '../fragments/organizationAttributes.api';
+import { OrganizationAttributesFragmentDoc } from '../../../../../../shared/entities/organization/graphql/organizationAttributes.api';
 import { OrganizationMembersFragmentDoc } from '../fragments/organizationMembers.api';
 import * as VueApolloComposable from '@vue/apollo-composable';
 import * as VueCompositionApi from 'vue';

+ 1 - 1
app/frontend/apps/mobile/entities/organization/graphql/subscriptions/organizationUpdates.api.ts

@@ -1,7 +1,7 @@
 import * as Types from '#shared/graphql/types.ts';
 
 import gql from 'graphql-tag';
-import { OrganizationAttributesFragmentDoc } from '../fragments/organizationAttributes.api';
+import { OrganizationAttributesFragmentDoc } from '../../../../../../shared/entities/organization/graphql/organizationAttributes.api';
 import { OrganizationMembersFragmentDoc } from '../fragments/organizationMembers.api';
 import * as VueApolloComposable from '@vue/apollo-composable';
 import * as VueCompositionApi from 'vue';

+ 27 - 0
app/frontend/shared/components/Form/fields/FieldOrganization/composables/useFieldOrganizationInitialOptionBuilder.ts

@@ -0,0 +1,27 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
+import type { AutocompleteOrganizationProps } from '#shared/components/Form/fields/FieldOrganization/types.ts'
+import { getAutoCompleteOption } from '#shared/entities/organization/utils/getAutoCompleteOption.ts'
+import type { Organization } from '#shared/graphql/types.ts'
+import type { ObjectLike } from '#shared/types/utils.ts'
+
+const useFormFieldOrganizationInitialOptionBuilder = () => {
+  return (
+    initialEntityObject: ObjectLike,
+    value: SelectValue,
+    context: AutocompleteOrganizationProps['context'],
+  ) => {
+    if (!context.belongsToObjectField || !initialEntityObject) return null
+
+    const belongsToObject = initialEntityObject[
+      context.belongsToObjectField
+    ] as Organization
+
+    if (!belongsToObject) return null
+
+    return getAutoCompleteOption(belongsToObject)
+  }
+}
+
+export default useFormFieldOrganizationInitialOptionBuilder

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