Просмотр исходного кода

Feature: Mobile - Add FieldSecurity field

Vladimir Sheremet 2 лет назад
Родитель
Сommit
bc7bf4a535

+ 21 - 19
app/frontend/apps/mobile/form/theme/global/getCoreClasses.ts

@@ -5,6 +5,7 @@ import { addAbsoluteFloatingLabel } from './addAbsoluteFloatingLabel'
 import { addFloatingTextareaLabel } from './addFloatingTextareaLabel'
 import { addBlockFloatingLabel } from './addBlockFloatingLabel'
 import type { Classes } from './utils'
+import { extendClasses } from './utils'
 
 export const addDateLabel = (classes: Classes = {}): Classes => {
   const newClasses = addAbsoluteFloatingLabel(classes)
@@ -57,27 +58,21 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
     datetime: addDateLabel(classes.datetime),
     editor: addFloatingTextareaLabel(classes.editor),
     textarea: addFloatingTextareaLabel(classes.textarea),
-    checkbox: {
+    checkbox: extendClasses(classes.checkbox, {
       outer: 'formkit-invalid:bg-red-dark formkit-errors:bg-red-dark',
-      wrapper: `${
-        classes.checkbox?.wrapper || ''
-      } ltr:pl-2 rtl:pr-2 w-full justify-between`,
-      label: `${classes.checkbox?.label || ''} formkit-required:required`,
-      input: ` ${
-        classes.checkbox?.input || ''
-      } h-4 w-4 border-[1.5px] border-white rounded-sm bg-transparent focus:border-blue focus:bg-blue-highlight checked:focus:color-blue checked:bg-blue checked:border-blue checked:focus:bg-blue checked:hover:bg-blue`,
-    },
-    toggle: {
-      ...classes.toggle,
-      outer: `${
-        classes.toggle?.outer || ''
-      } relative px-2 formkit-invalid:bg-red-dark formkit-errors:bg-red-dark`,
-      wrapper: `${classes.toggle?.wrapper || ''} inline-flex w-full h-14 px-2`,
-      label: `${
-        classes.toggle?.label || ''
-      } flex items-center w-full h-full text-base cursor-pointer formkit-required:required`,
+      wrapper: 'ltr:pl-2 rtl:pr-2 w-full justify-between',
+      label: 'formkit-required:required',
+      input:
+        'h-4 w-4 border-[1.5px] border-white rounded-sm bg-transparent focus:border-blue focus:bg-blue-highlight checked:focus:color-blue checked:bg-blue checked:border-blue checked:focus:bg-blue checked:hover:bg-blue',
+    }),
+    toggle: extendClasses(classes.toggle, {
+      outer:
+        'relative px-2 formkit-invalid:bg-red-dark formkit-errors:bg-red-dark',
+      wrapper: 'inline-flex w-full h-14 px-2',
+      label:
+        'flex items-center w-full h-full text-base cursor-pointer formkit-required:required',
       inner: `${classes.toggle?.inner || ''} flex items-center h-full`,
-    },
+    }),
     tags: addBlockFloatingLabel(classes.tags),
     select: addSelectLabel(classes.select),
     treeselect: addBlockFloatingLabel(classes.treeselect),
@@ -87,6 +82,13 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
     recipient: addBlockFloatingLabel(classes.recipient),
     button: addButtonVariants(classes.button),
     submit: addButtonVariants(classes.submit),
+    security: extendClasses(classes.security, {
+      outer:
+        'relative px-2 formkit-invalid:bg-red-dark formkit-errors:bg-red-dark',
+      wrapper: 'inline-flex w-full h-14 px-2',
+      label:
+        'formkit-required:required flex text-white items-center w-full h-full text-base',
+    }),
   }
 }
 

+ 16 - 0
app/frontend/apps/mobile/form/theme/global/utils.ts

@@ -3,3 +3,19 @@
 export type Classes = Record<string, string>
 
 export const clean = (str: string) => str.replace(/\s{2,}/g, ' ').trim()
+export const extendClasses = (
+  originalClasses: Classes | undefined,
+  newClasses: Classes,
+) => {
+  const mergedClasses = { ...newClasses }
+
+  Object.entries(originalClasses || {}).forEach(([type, originalClass]) => {
+    if (!(type in mergedClasses)) {
+      mergedClasses[type] = originalClass
+    } else {
+      mergedClasses[type] = clean(`${originalClass} ${newClasses[type]}`)
+    }
+  })
+
+  return mergedClasses
+}

+ 9 - 0
app/frontend/apps/mobile/pages/playground/views/PlaygroundOverview.vue

@@ -12,6 +12,15 @@ import CommonStepper from '@mobile/components/CommonStepper/CommonStepper.vue'
 import { ref } from 'vue'
 
 const linkSchemaRaw = [
+  {
+    type: 'security',
+    name: 'security',
+    label: 'Security',
+    required: true,
+    props: {
+      allowed: ['sign', 'encryption'],
+    },
+  },
   {
     type: 'editor',
     name: 'editor',

+ 3 - 0
app/frontend/shared/components/CommonIcon/assets/mobile/not-signed.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4,2.3c-0.8-0.8-2-0.8-2.8,0L10,3C9.5,3.4,8.9,3.6,8.3,3.5 L7.5,3.4C6.4,3.2,5.4,3.9,5.2,5L5,5.9C4.9,6.5,4.6,7,4,7.3L3.2,7.7c-1,0.5-1.3,1.7-0.9,2.6l0.4,0.8c0.3,0.6,0.3,1.2,0,1.8l-0.4,0.8 c-0.5,1-0.1,2.1,0.9,2.6L4,16.7c0.5,0.3,0.9,0.8,1,1.4L5.2,19c0.2,1.1,1.2,1.8,2.3,1.6l0.9-0.1c0.6-0.1,1.2,0.1,1.7,0.5l0.6,0.6 c0.8,0.8,2,0.8,2.8,0L14,21c0.4-0.4,1.1-0.6,1.7-0.5l0.9,0.1c1.1,0.2,2.1-0.6,2.2-1.6l0.2-0.9c0.1-0.6,0.5-1.1,1-1.4l0.8-0.4 c1-0.5,1.3-1.7,0.9-2.6l-0.4-0.8c-0.3-0.6-0.3-1.2,0-1.8l0.4-0.8c0.5-1,0.1-2.1-0.9-2.6L20,7.3c-0.5-0.3-0.9-0.8-1-1.4L18.8,5 c-0.2-1.1-1.2-1.8-2.2-1.6l-0.9,0.1C15.1,3.6,14.5,3.4,14,3L13.4,2.3z M7.9,15.1l3-3l-3-3L9,8l3,3l3-3l1.1,1.1l-3,3l3,3L15,16.1 l-3-3l-3,3L7.9,15.1z" />
+</svg>

+ 3 - 0
app/frontend/shared/components/CommonIcon/assets/mobile/signed.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4,2.3c-0.8-0.8-2-0.8-2.8,0L10,3C9.5,3.4,8.9,3.6,8.3,3.5 L7.5,3.4C6.4,3.2,5.4,3.9,5.2,5L5,5.9C4.9,6.5,4.6,7,4,7.3L3.2,7.7c-1,0.5-1.3,1.7-0.9,2.6l0.4,0.8c0.3,0.6,0.3,1.2,0,1.8l-0.4,0.8 c-0.5,1-0.1,2.1,0.9,2.6L4,16.7c0.5,0.3,0.9,0.8,1,1.4L5.2,19c0.2,1.1,1.2,1.8,2.3,1.6l0.9-0.1c0.6-0.1,1.2,0.1,1.7,0.5l0.6,0.6 c0.8,0.8,2,0.8,2.8,0L14,21c0.4-0.4,1.1-0.6,1.7-0.5l0.9,0.1c1.1,0.2,2.1-0.6,2.2-1.6l0.2-0.9c0.1-0.6,0.5-1.1,1-1.4l0.8-0.4 c1-0.5,1.3-1.7,0.9-2.6l-0.4-0.8c-0.3-0.6-0.3-1.2,0-1.8l0.4-0.8c0.5-1,0.1-2.1-0.9-2.6L20,7.3c-0.5-0.3-0.9-0.8-1-1.4L18.8,5 c-0.2-1.1-1.2-1.8-2.2-1.6l-0.9,0.1C15.1,3.6,14.5,3.4,14,3L13.4,2.3z M11.6,16l6-7l-1.1-1l-5.5,6.4l-3.4-3l-1,1.1l4,3.5 c0.2,0.1,0.3,0.2,0.5,0.2C11.3,16.2,11.4,16.1,11.6,16z" />
+</svg>

+ 7 - 4
app/frontend/shared/components/Form/composables/useValue.ts

@@ -3,8 +3,11 @@
 import { computed, type Ref } from 'vue'
 import { type FormFieldContext } from '../types/field'
 
-const useValue = (context: Ref<FormFieldContext<{ multiple?: boolean }>>) => {
-  const currentValue = computed(() => context.value._value)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const useValue = <T = any>(
+  context: Ref<FormFieldContext<{ multiple?: boolean }>>,
+) => {
+  const currentValue = computed(() => context.value._value as T)
 
   const hasValue = computed(() => {
     return context.value.fns.hasValue(currentValue.value)
@@ -14,9 +17,9 @@ const useValue = (context: Ref<FormFieldContext<{ multiple?: boolean }>>) => {
     context.value.multiple ? currentValue.value : [currentValue.value],
   )
 
-  const isCurrentValue = (value: unknown) => {
+  const isCurrentValue = (value: T) => {
     if (!hasValue.value) return false
-    return valueContainer.value.includes(value)
+    return (valueContainer.value as unknown as T[]).includes(value)
   }
 
   const clearValue = (asyncSettling = true) => {

+ 25 - 0
app/frontend/shared/components/Form/fields/FieldSecurity/FieldSecurity.story.vue

@@ -0,0 +1,25 @@
+<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { FormKit } from '@formkit/vue'
+import FormGroup from '../../FormGroup.vue'
+</script>
+
+<template>
+  <Story>
+    <Variant title="Allow all">
+      <FormGroup>
+        <FormKit
+          type="security"
+          label="Security"
+          :allowed="['encryption', 'sign']"
+        />
+      </FormGroup>
+    </Variant>
+    <Variant title="Allow none">
+      <FormGroup>
+        <FormKit type="security" label="Security" :allowed="[]" />
+      </FormGroup>
+    </Variant>
+  </Story>
+</template>

+ 93 - 0
app/frontend/shared/components/Form/fields/FieldSecurity/FieldSecurity.vue

@@ -0,0 +1,93 @@
+<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { useTraverseOptions } from '@shared/composables/useTraverseOptions'
+import { computed, ref, toRef } from 'vue'
+import useValue from '../../composables/useValue'
+import type { FormFieldContext } from '../../types/field'
+
+type SecurityOption = 'encryption' | 'sign'
+type SecurityValue = SecurityOption[] | null | undefined
+type SecurityAllowed = SecurityOption[]
+
+interface FieldSecurityProps {
+  context: FormFieldContext<{
+    disabled?: boolean
+    allowed?: SecurityAllowed
+  }>
+}
+
+const props = defineProps<FieldSecurityProps>()
+
+const { currentValue } = useValue<SecurityValue>(toRef(props, 'context'))
+
+const isCurrentValue = (option: SecurityOption) =>
+  currentValue.value?.includes(option) ?? false
+
+const options = computed(() => {
+  return [
+    {
+      option: 'encryption',
+      label: 'Encrypt',
+      icon: isCurrentValue('encryption') ? 'mobile-lock' : 'mobile-unlock',
+    },
+    {
+      option: 'sign',
+      label: 'Sign',
+      icon: isCurrentValue('sign') ? 'mobile-signed' : 'mobile-not-signed',
+    },
+  ] as const
+})
+
+const isDisabled = (option: SecurityOption) =>
+  props.context.disabled || !props.context.allowed?.includes(option)
+
+const toggleOption = (name: SecurityOption) => {
+  if (isDisabled(name)) return
+  let currentOptions = currentValue.value || []
+
+  if (currentOptions.includes(name))
+    currentOptions = currentOptions.filter((option) => option !== name)
+  else currentOptions = [...currentOptions, name]
+
+  props.context.node.input(currentOptions.sort())
+}
+
+const optionsContainer = ref<HTMLElement>()
+
+useTraverseOptions(optionsContainer, { direction: 'horizontal' })
+</script>
+
+<template>
+  <div
+    ref="optionsContainer"
+    role="listbox"
+    class="flex h-full items-center gap-2"
+    :aria-label="context.label"
+    aria-multiselectable="true"
+    aria-orientation="horizontal"
+  >
+    <button
+      v-for="{ option, label, icon } of options"
+      :key="option"
+      role="option"
+      type="button"
+      class="flex select-none items-center gap-1 rounded-md px-2 py-1 text-base font-bold"
+      :class="{
+        'bg-gray-600/50 text-white/30': isDisabled(option),
+        'cursor-pointer': !isDisabled(option),
+        'bg-gray-300 text-white': !isCurrentValue(option),
+        'bg-white text-black': isCurrentValue(option),
+      }"
+      :tabindex="isDisabled(option) ? -1 : 0"
+      :disabled="isDisabled(option)"
+      :aria-selected="isCurrentValue(option)"
+      :aria-disabled="isDisabled(option)"
+      @click="toggleOption(option)"
+      @keydown.space.prevent="toggleOption(option)"
+    >
+      <CommonIcon :name="icon" size="tiny" decorative />
+      {{ $t(label) }}
+    </button>
+  </div>
+</template>

+ 91 - 0
app/frontend/shared/components/Form/fields/FieldSecurity/__tests__/FieldSecurity.spec.ts

@@ -0,0 +1,91 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import { FormKit } from '@formkit/vue'
+import { renderComponent } from '@tests/support/components'
+
+const renderSecurityField = (props: any = {}) => {
+  return renderComponent(FormKit, {
+    form: true,
+    formField: true,
+    props: {
+      type: 'security',
+      name: 'security',
+      label: 'Security',
+      ...props,
+    },
+  })
+}
+
+describe('FieldSecurity', () => {
+  it('renders security options', async () => {
+    const view = renderSecurityField({
+      allowed: ['encryption', 'sign'],
+    })
+
+    const encrypt = view.getByRole('option', { name: 'Encrypt' })
+    const sign = view.getByRole('option', { name: 'Sign' })
+
+    expect(encrypt).toBeInTheDocument()
+    expect(sign).toBeInTheDocument()
+
+    expect(encrypt).toBeEnabled()
+    expect(sign).toBeEnabled()
+  })
+
+  it('can check and uncheck options', async () => {
+    const view = renderSecurityField({
+      allowed: ['encryption', 'sign'],
+    })
+
+    const encrypt = view.getByRole('option', { name: 'Encrypt' })
+    const sign = view.getByRole('option', { name: 'Sign' })
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+
+    await view.events.click(encrypt)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'true')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+
+    await view.events.click(encrypt)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+
+    await view.events.click(sign)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'true')
+
+    await view.events.click(sign)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+  })
+
+  it("doesn't check disabled options", async () => {
+    const view = renderSecurityField({
+      allowed: [],
+    })
+
+    const encrypt = view.getByRole('option', { name: 'Encrypt' })
+    const sign = view.getByRole('option', { name: 'Sign' })
+
+    expect(encrypt).toBeDisabled()
+    expect(sign).toBeDisabled()
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+
+    await view.events.click(encrypt)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+
+    await view.events.click(encrypt)
+
+    expect(encrypt).toHaveAttribute('aria-selected', 'false')
+    expect(sign).toHaveAttribute('aria-selected', 'false')
+  })
+})

+ 11 - 0
app/frontend/shared/components/Form/fields/FieldSecurity/index.ts

@@ -0,0 +1,11 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import createInput from '@shared/form/core/createInput'
+import FieldSecurity from './FieldSecurity.vue'
+
+const fieldDefinition = createInput(FieldSecurity, ['allowed'])
+
+export default {
+  fieldType: 'security',
+  definition: fieldDefinition,
+}