Browse Source

Feature: Mobile - Form: FieldSelect/Treeselect historical values.

Dusan Vuckovic 2 years ago
parent
commit
4295fe50a1

+ 71 - 13
app/frontend/shared/components/Form/composables/useSelectOptions.ts

@@ -18,8 +18,10 @@ const useSelectOptions = (
   options: Ref<SelectOption[] | FlatSelectOption[] | AutoCompleteOption[]>,
   context: Ref<
     FormFieldContext<{
+      historicalOptions?: Record<string, string>
       multiple?: boolean
       noOptionsLabelTranslation?: boolean
+      rejectNonExistentValues?: boolean
       sorting?: SelectOptionSorting
     }>
   >,
@@ -34,22 +36,30 @@ const useSelectOptions = (
 ) => {
   const dialog = ref<HTMLElement>()
 
-  const { currentValue, hasValue, clearValue } = useValue(context)
+  const { currentValue, hasValue, valueContainer, clearValue } =
+    useValue(context)
 
-  const hasStatusProperty = computed(
-    () =>
-      options.value &&
-      options.value.some(
-        (option) => (option as SelectOption | FlatSelectOption).status,
-      ),
+  const appendedOptions: Ref<
+    SelectOption[] | FlatSelectOption[] | AutoCompleteOption[]
+  > = ref([])
+
+  const availableOptions = computed(() => [
+    ...(options.value || []),
+    ...appendedOptions.value,
+  ])
+
+  const hasStatusProperty = computed(() =>
+    availableOptions.value?.some(
+      (option) => (option as SelectOption | FlatSelectOption).status,
+    ),
   )
 
   const translatedOptions = computed(() => {
-    if (!options.value) return []
+    if (!availableOptions.value) return []
 
     const { noOptionsLabelTranslation } = context.value
 
-    return options.value.map(
+    return availableOptions.value.map(
       (option: SelectOption | FlatSelectOption | AutoCompleteOption) => {
         const label = noOptionsLabelTranslation
           ? option.label
@@ -188,9 +198,57 @@ const useSelectOptions = (
     if (targetElement) targetElement.focus()
   }
 
-  // Setup a watcher to clear the value if the related option goes missing.
-  //   This will only kick-in on subsequent mutations of the options prop.
-  const setupClearMissingOptionValue = () => {
+  // Setup a mechanism to handle missing options, including:
+  //   - appending historical options for current values
+  //   - clearing value in case options are missing
+  const setupMissingOptionHandling = () => {
+    const historicalOptions = context.value.historicalOptions || {}
+
+    // Append historical options to the list of available options, if:
+    //   - non-existent values are not supposed to be rejected
+    //   - we have a current value
+    //   - we have a list of historical options
+    if (
+      !context.value.rejectNonExistentValues &&
+      hasValue.value &&
+      historicalOptions
+    ) {
+      appendedOptions.value = valueContainer.value.reduce(
+        (
+          accumulator:
+            | SelectOption[]
+            | FlatSelectOption[]
+            | AutoCompleteOption[],
+          value: SelectValue,
+        ) => [
+          ...accumulator,
+
+          // Make sure the options are not duplicated!
+          ...(!options.value.some((option) => option.value === value) &&
+          historicalOptions[value.toString()]
+            ? [
+                {
+                  value,
+                  label: historicalOptions[value.toString()],
+                },
+              ]
+            : []),
+        ],
+        [],
+      )
+    }
+
+    // Reject non-existent values during the initialization phase.
+    //   Note that this behavior is controlled by a dedicated flag.
+    if (
+      context.value.rejectNonExistentValues &&
+      hasValue.value &&
+      typeof optionValueLookup.value[currentValue.value] === 'undefined'
+    )
+      clearValue()
+
+    // Set up a watcher that clears a missing option value on subsequent mutations of the options prop.
+    //   In this case, the dedicated flag is ignored.
     watch(
       () => options.value,
       () => {
@@ -217,7 +275,7 @@ const useSelectOptions = (
     selectOption,
     getDialogFocusTargets,
     advanceDialogFocus,
-    setupClearMissingOptionValue,
+    setupMissingOptionHandling,
   }
 }
 

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

@@ -30,7 +30,7 @@ const {
   getSelectedOptionIcon,
   getSelectedOptionLabel,
   getSelectedOptionStatus,
-  setupClearMissingOptionValue,
+  setupMissingOptionHandling,
 } = useSelectOptions(toRef(props.context, 'options'), contextReactive)
 
 const isSizeSmall = computed(() => props.context.size === 'small')
@@ -45,7 +45,7 @@ const openSelectDialog = () => {
 useFormBlock(contextReactive, openSelectDialog)
 
 useSelectPreselect(sortedOptions, contextReactive)
-setupClearMissingOptionValue()
+setupMissingOptionHandling()
 </script>
 
 <template>

+ 85 - 1
app/frontend/shared/components/Form/fields/FieldSelect/__tests__/FieldSelect.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, keyBy } from 'lodash-es'
 import { getByText, waitFor } from '@testing-library/vue'
 import { FormKit } from '@formkit/vue'
 import { renderComponent } from '@tests/support/components'
@@ -314,6 +314,90 @@ describe('Form - Field - Select - Options', () => {
     expect(wrapper.queryByIconName(iconOptions[0].icon)).toBeInTheDocument()
     expect(wrapper.queryByIconName(iconOptions[1].icon)).toBeInTheDocument()
   })
+
+  it('supports historical options', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        type: 'select',
+        value: 3,
+        options: testOptions,
+        historicalOptions: {
+          ...keyBy(testOptions, 'value'),
+          3: 'Item D',
+        },
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(testOptions.length + 1)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    await wrapper.events.click(selectOptions[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.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(testOptions.length + 1)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+  })
+
+  it('supports rejection of non-existent values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        type: 'select',
+        value: 3,
+        options: testOptions,
+        clearable: true, // otherwise it defaults to the first option
+        rejectNonExistentValues: true,
+      },
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(undefined)
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(testOptions.length)
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+  })
 })
 
 describe('Form - Field - Select - Features', () => {

+ 2 - 0
app/frontend/shared/components/Form/fields/FieldSelect/index.ts

@@ -29,9 +29,11 @@ const fieldDefinition = createInput(
   FieldSelectInput,
   [
     'clearable',
+    'historicalOptions',
     'multiple',
     'noOptionsLabelTranslation',
     'options',
+    'rejectNonExistentValues',
     'size',
     'sorting',
   ],

+ 2 - 0
app/frontend/shared/components/Form/fields/FieldSelect/types.ts

@@ -23,9 +23,11 @@ export type SelectSize = 'small' | 'medium'
 export type SelectContext = FormFieldContext<{
   clearable?: boolean
   disabled?: boolean
+  historicalOptions: Record<string, string>
   multiple?: boolean
   noOptionsLabelTranslation?: boolean
   options: SelectOption[]
+  rejectNonExistentValues?: boolean
   size?: SelectSize
   sorting?: SelectOptionSorting
 }>

+ 19 - 17
app/frontend/shared/components/Form/fields/FieldTreeSelect/FieldTreeSelectInput.vue

@@ -42,21 +42,6 @@ const dialog = useDialog({
   },
 })
 
-const openModal = () => {
-  return dialog.open({
-    context: toRef(props, 'context'),
-    name: nameDialog,
-    currentPath,
-    onPush(option: FlatSelectOption) {
-      currentPath.value.push(option)
-    },
-    onPop() {
-      currentPath.value.pop()
-    },
-  })
-}
-
-// TODO: could maybe be moved to a other place, because it's currently duplicated
 const flattenOptions = (
   options: TreeSelectOption[],
   parents: SelectValue[] = [],
@@ -91,14 +76,31 @@ const focusFirstTarget = (targetElements?: HTMLElement[]) => {
 
 const {
   hasStatusProperty,
+  sortedOptions,
   optionValueLookup,
   getSelectedOptionIcon,
   getSelectedOptionLabel,
   getSelectedOptionStatus,
   getDialogFocusTargets,
-  setupClearMissingOptionValue,
+  setupMissingOptionHandling,
 } = useSelectOptions(flatOptions, toRef(props, 'context'))
 
+const openModal = () => {
+  return dialog.open({
+    context: toRef(props, 'context'),
+    name: nameDialog,
+    currentPath,
+    flatOptions,
+    sortedOptions,
+    onPush(option: FlatSelectOption) {
+      currentPath.value.push(option)
+    },
+    onPop() {
+      currentPath.value.pop()
+    },
+  })
+}
+
 const getSelectedOptionParents = (selectedValue: string | number) =>
   (optionValueLookup.value[selectedValue] &&
     (optionValueLookup.value[selectedValue] as FlatSelectOption).parents) ||
@@ -128,7 +130,7 @@ const onInputClick = () => {
 
 useSelectPreselect(flatOptions, contextReactive)
 useFormBlock(contextReactive, onInputClick)
-setupClearMissingOptionValue()
+setupMissingOptionHandling()
 </script>
 
 <template>

+ 9 - 25
app/frontend/shared/components/Form/fields/FieldTreeSelect/FieldTreeSelectInputDialog.vue

@@ -8,7 +8,7 @@ import { closeDialog } from '@shared/composables/useDialog'
 import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
 import { escapeRegExp } from 'lodash-es'
 import useSelectOptions from '../../composables/useSelectOptions'
-import type { TreeSelectContext, TreeSelectOption } from './types'
+import type { TreeSelectContext } from './types'
 import { FlatSelectOption } from './types'
 import type { SelectOption } from '../FieldSelect'
 import useValue from '../../composables/useValue'
@@ -17,6 +17,8 @@ const props = defineProps<{
   context: TreeSelectContext
   name: string
   currentPath: FlatSelectOption[]
+  flatOptions: FlatSelectOption[]
+  sortedOptions: FlatSelectOption[]
 }>()
 
 const { isCurrentValue } = useValue(toRef(props, 'context'))
@@ -50,23 +52,6 @@ const popFromPath = () => {
   emit('pop')
 }
 
-const flattenOptions = (
-  options: TreeSelectOption[],
-  parents: (string | number | boolean)[] = [],
-): FlatSelectOption[] =>
-  options &&
-  options.reduce((flatOptions: FlatSelectOption[], { children, ...option }) => {
-    flatOptions.push({
-      ...option,
-      parents,
-      hasChildren: Boolean(children),
-    })
-    if (children)
-      flatOptions.push(...flattenOptions(children, [...parents, option.value]))
-    return flatOptions
-  }, [])
-
-const flatOptions = computed(() => flattenOptions(props.context.options))
 const contextReactive = toRef(props, 'context')
 
 watch(() => contextReactive.value.noFiltering, clearFilter)
@@ -107,13 +92,12 @@ const nextPageCallback = (
 
 const {
   dialog,
-  sortedOptions,
   getSelectedOptionLabel,
   selectOption,
   getDialogFocusTargets,
   advanceDialogFocus,
 } = useSelectOptions(
-  flatOptions,
+  toRef(props, 'flatOptions'),
   contextReactive,
   previousPageCallback,
   nextPageCallback,
@@ -124,11 +108,11 @@ const deaccent = (s: string) =>
 
 const filteredOptions = computed(() => {
   // In case we are not currently filtering for a parent, search across all options.
-  let options = sortedOptions.value
+  let options = props.sortedOptions
 
   // Otherwise, search across options which are children of the current parent.
   if (currentParent.value)
-    options = sortedOptions.value.filter((option) =>
+    options = props.sortedOptions.filter((option) =>
       (option as FlatSelectOption).parents.includes(currentParent.value?.value),
     )
 
@@ -153,12 +137,12 @@ const select = (option: FlatSelectOption) => {
 const currentOptions = computed(() => {
   // In case we are not currently filtering for a parent, return only top-level options.
   if (!currentParent.value)
-    return sortedOptions.value.filter(
-      (option) => !(option as FlatSelectOption).parents.length,
+    return props.sortedOptions.filter(
+      (option) => !(option as FlatSelectOption).parents?.length,
     )
 
   // Otherwise, return all options which are children of the current parent.
-  return sortedOptions.value.filter(
+  return props.sortedOptions.filter(
     (option) =>
       (option as FlatSelectOption).parents.length &&
       (option as FlatSelectOption).parents[

+ 81 - 1
app/frontend/shared/components/Form/fields/FieldTreeSelect/__tests__/FieldTreeSelect.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, keyBy } from 'lodash-es'
 import { getByText, waitFor } from '@testing-library/vue'
 import { FormKit } from '@formkit/vue'
 import { renderComponent } from '@tests/support/components'
@@ -363,6 +363,86 @@ describe('Form - Field - TreeSelect - Options', () => {
     expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
   })
 
+  it('supports historical options', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        type: 'treeselect',
+        value: 10,
+        options: testOptions,
+        historicalOptions: {
+          ...keyBy(testOptions, 'value'),
+          10: 'Item D',
+        },
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(4)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    await wrapper.events.click(selectOptions[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.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(4)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+  })
+
+  it('supports rejection of non-existent values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        type: 'treeselect',
+        value: 10,
+        options: testOptions,
+        clearable: true, // otherwise it defaults to the first option
+        rejectNonExistentValues: true,
+      },
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(undefined)
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(3)
+  })
+
   it('supports disabled property', async () => {
     const disabledOptions = [
       {

+ 3 - 1
app/frontend/shared/components/Form/fields/FieldTreeSelect/index.ts

@@ -9,10 +9,12 @@ const fieldDefinition = createInput(
   FieldTreeSelectInput,
   [
     'clearable',
-    'noFiltering',
+    'historicalOptions',
     'multiple',
+    'noFiltering',
     'noOptionsLabelTranslation',
     'options',
+    'rejectNonExistentValues',
     'sorting',
   ],
   { features: [addLink, formUpdaterTrigger()] },

+ 3 - 1
app/frontend/shared/components/Form/fields/FieldTreeSelect/types.ts

@@ -14,10 +14,12 @@ export type FlatSelectOption = SelectOption & {
 
 export type TreeSelectContext = FormFieldContext<{
   clearable?: boolean
-  noFiltering?: boolean
   disabled?: boolean
+  historicalOptions: Record<string, string>
   multiple?: boolean
+  noFiltering?: boolean
   noOptionsLabelTranslation?: boolean
   options: TreeSelectOption[]
+  rejectNonExistentValues?: boolean
   sorting?: SelectOptionSorting
 }>