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

Feature: Desktop-View - Implement Notifications section

Benjamin Scharf 10 месяцев назад
Родитель
Сommit
b72e5ce04c

+ 1 - 1
app/assets/javascripts/app/views/profile/notification.jst.eco

@@ -35,7 +35,7 @@
               <td><%- @P(group, 'name') %>
               <td class="u-positionOrigin">
                 <label class="checkbox-replacement checkbox-replacement--fullscreen">
-                  <input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(@config.group_ids, group.id.toString()): %>checked<% end %>/>
+                  <input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(_.map(@config.group_ids, (group_id) -> group_id.toString()), group.id.toString()): %>checked<% end %>/>
                   <%- @Icon('checkbox', 'icon-unchecked') %>
                   <%- @Icon('checkbox-checked', 'icon-checked') %>
                 </label>

+ 48 - 10
app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue

@@ -12,6 +12,29 @@ export interface Props {
 }
 
 defineProps<Props>()
+
+// :INFO - This would only would work on runtime, when keys are computed
+// :TODO - Find a way to infer the types on compile time or remove it completely
+// defineSlots<{
+//   [key: `column-cell-${TableHeader['key']}`]: [
+//     { item: TableHeader; header: TableHeader },
+//   ]
+//   [key: `header-suffix-${TableHeader['key']}`]: [{ item: TableHeader }]
+//   [key: `item-suffix-${TableHeader['key']}`]: [{ item: TableHeader }]
+//   test: []
+// }>()
+
+//  Styling
+const cellAlignmentClasses = {
+  right: 'text-right',
+  center: 'text-center',
+  left: 'text-left',
+}
+
+const rowBackgroundClasses = 'bg-blue-200 dark:bg-gray-700'
+
+const columnSeparatorClasses =
+  'border-r border-neutral-100 dark:border-gray-900'
 </script>
 
 <template>
@@ -21,9 +44,14 @@ defineProps<Props>()
         v-for="header in headers"
         :key="header.key"
         class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
+        :class="[
+          header.columnClass,
+          header.columnSeparator && columnSeparatorClasses,
+        ]"
       >
         <CommonLabel
           class="font-normal text-stone-200 dark:text-neutral-500"
+          :class="[cellAlignmentClasses[header.alignContent || 'left']]"
           size="small"
           >{{
             $t(header.label, ...(header.labelPlaceholder || []))
@@ -46,17 +74,27 @@ defineProps<Props>()
           v-for="header in headers"
           :key="`${item.id}-${header.key}`"
           class="h-10 p-2.5 text-sm text-gray-100 first:rounded-s-md last:rounded-e-md dark:text-neutral-400"
-          :class="{ 'bg-blue-200 dark:bg-gray-700': (index + 1) % 2 }"
+          :class="[
+            (index + 1) % 2 && rowBackgroundClasses,
+            header.columnSeparator && columnSeparatorClasses,
+            cellAlignmentClasses[header.alignContent || 'left'],
+          ]"
         >
-          <CommonLabel class="text-black dark:text-white">
-            <template v-if="!item[header.key]">-</template>
-            <template v-else-if="header.type === 'timestamp'">
-              <CommonDateTime :date-time="item[header.key] as string" />
-            </template>
-            <template v-else>
-              {{ item[header.key] }}
-            </template>
-          </CommonLabel>
+          <slot
+            :name="`column-cell-${header.key}`"
+            :item="item"
+            :header="header"
+          >
+            <CommonLabel class="text-black dark:text-white">
+              <template v-if="!item[header.key]">-</template>
+              <template v-else-if="header.type === 'timestamp'">
+                <CommonDateTime :date-time="item[header.key] as string" />
+              </template>
+              <template v-else>
+                {{ item[header.key] }}
+              </template>
+            </CommonLabel>
+          </slot>
 
           <slot :name="`item-suffix-${header.key}`" :item="item" />
         </td>

+ 0 - 1
app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts

@@ -39,7 +39,6 @@ const tableActions: MenuItem[] = [
 
 const renderTable = (props: Props, options = {}) => {
   return renderComponent(CommonSimpleTable, {
-    shallow: false,
     ...options,
     props,
   })

+ 8 - 4
app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt

@@ -7,7 +7,7 @@
       class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
     >
       <common-label-stub
-        class="font-normal text-stone-200 dark:text-neutral-500"
+        class="font-normal text-stone-200 dark:text-neutral-500 text-left"
         size="small"
       />
       
@@ -17,7 +17,7 @@
       class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
     >
       <common-label-stub
-        class="font-normal text-stone-200 dark:text-neutral-500"
+        class="font-normal text-stone-200 dark:text-neutral-500 text-left"
         size="small"
       />
       
@@ -38,24 +38,28 @@
     <tr>
       
       <td
-        class="h-10 p-2.5 text-sm text-gray-100 first:rounded-s-md last:rounded-e-md dark:text-neutral-400 bg-blue-200 dark:bg-gray-700"
+        class="h-10 p-2.5 text-sm text-gray-100 first:rounded-s-md last:rounded-e-md dark:text-neutral-400 bg-blue-200 dark:bg-gray-700 text-left"
       >
+        
         <common-label-stub
           class="text-black dark:text-white"
           size="medium"
         />
         
         
+        
       </td>
       <td
-        class="h-10 p-2.5 text-sm text-gray-100 first:rounded-s-md last:rounded-e-md dark:text-neutral-400 bg-blue-200 dark:bg-gray-700"
+        class="h-10 p-2.5 text-sm text-gray-100 first:rounded-s-md last:rounded-e-md dark:text-neutral-400 bg-blue-200 dark:bg-gray-700 text-left"
       >
+        
         <common-label-stub
           class="text-black dark:text-white"
           size="medium"
         />
         
         
+        
       </td>
       
       <td

+ 9 - 4
app/frontend/apps/desktop/components/CommonSimpleTable/types.ts

@@ -1,12 +1,17 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
-export interface TableHeader {
-  key: string
+type TableColumnType = 'timestamp'
+
+export interface TableHeader<K = string> {
+  key: K
   label: string
   labelPlaceholder?: string[]
-  type?: 'timestamp'
+  columnClass?: string
+  columnSeparator?: boolean
+  alignContent?: 'center' | 'right'
+  type?: TableColumnType
+  [key: string]: unknown
 }
-
 export interface TableItem {
   [key: string]: unknown
   id: string | number

+ 165 - 0
app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue

@@ -0,0 +1,165 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { toRef } from 'vue'
+import { cloneDeep } from 'lodash-es'
+import CommonSimpleTable from '#desktop/components/CommonSimpleTable/CommonSimpleTable.vue'
+import useValue from '#shared/components/Form/composables/useValue.ts'
+
+import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
+import type { TableHeader } from '#desktop/components/CommonSimpleTable/types.ts'
+import {
+  NotificationMatrixColumnKey,
+  NotificationMatrixPathKey,
+  NotificationMatrixRowKey,
+} from './types.ts'
+
+const props = defineProps<{
+  context: FormFieldContext
+}>()
+
+const context = toRef(props, 'context')
+
+const { localValue } = useValue(context)
+
+const tableHeaders: TableHeader[] = [
+  {
+    key: 'name',
+    label: __('Name'),
+  },
+  {
+    key: NotificationMatrixColumnKey.MyTickets,
+    path: NotificationMatrixPathKey.Criteria,
+    label: __('My tickets'),
+    alignContent: 'center',
+    columnClass: 'w-20',
+  },
+  {
+    key: NotificationMatrixColumnKey.NotAssigned,
+    path: NotificationMatrixPathKey.Criteria,
+    label: __('Not assigned'),
+    alignContent: 'center',
+    columnClass: 'w-20',
+  },
+  {
+    key: NotificationMatrixColumnKey.SubscribedTickets,
+    path: NotificationMatrixPathKey.Criteria,
+    label: __('Subscribed tickets'),
+    alignContent: 'center',
+    columnClass: 'w-20',
+  },
+  {
+    key: NotificationMatrixColumnKey.AllTickets,
+    path: NotificationMatrixPathKey.Criteria,
+    label: __('All tickets'),
+    alignContent: 'center',
+    columnClass: 'w-20',
+    columnSeparator: true,
+  },
+  {
+    key: NotificationMatrixColumnKey.AlsoNotifyViaEmail,
+    path: NotificationMatrixPathKey.Channel,
+    label: __('Also notify via email'),
+    alignContent: 'center',
+    columnClass: 'w-20',
+  },
+]
+
+const tableItems = [
+  {
+    id: 1,
+    key: NotificationMatrixRowKey.Create,
+    name: __('New ticket'),
+  },
+  {
+    id: 2,
+    key: NotificationMatrixRowKey.Update,
+    name: __('Ticket update'),
+  },
+  {
+    id: 3,
+    key: NotificationMatrixRowKey.ReminderReached,
+    name: __('Ticket reminder reached'),
+  },
+  {
+    id: 4,
+    key: NotificationMatrixRowKey.Escalation,
+    name: __('Ticket escalation'),
+  },
+]
+
+const valueLookup = (
+  rowKey: NotificationMatrixRowKey,
+  pathKey: NotificationMatrixPathKey,
+  columnKey: NotificationMatrixColumnKey,
+) => {
+  const row = localValue.value?.[rowKey]
+  if (!row) return undefined
+
+  return row[pathKey]?.[columnKey]
+}
+
+const updateValue = (
+  rowKey: NotificationMatrixRowKey,
+  pathKey: NotificationMatrixPathKey,
+  columnKey: NotificationMatrixColumnKey,
+  state: boolean | undefined,
+) => {
+  const values = cloneDeep(localValue.value) || {}
+
+  values[rowKey] = values[rowKey] || {}
+  values[rowKey][pathKey] = values[rowKey][pathKey] || {}
+  values[rowKey][pathKey][columnKey] = Boolean(state)
+
+  localValue.value = values
+}
+</script>
+
+<template>
+  <output
+    :id="context.id"
+    :class="context.classes.input"
+    :name="context.node.name"
+    :aria-disabled="context.disabled"
+    :aria-describedby="context.describedBy"
+    v-bind="context.attrs"
+  >
+    <CommonSimpleTable
+      class="mb-4 w-full"
+      :headers="tableHeaders"
+      :items="tableItems"
+    >
+      <template
+        v-for="key in NotificationMatrixColumnKey"
+        :key="key"
+        #[`column-cell-${key}`]="{ item, header }"
+      >
+        <FormKit
+          :id="`notifications_${item.key}_${header.path}_${header.key}`"
+          :model-value="
+            valueLookup(
+              item.key as NotificationMatrixRowKey,
+              header.path as NotificationMatrixPathKey,
+              header.key as NotificationMatrixColumnKey,
+            )
+          "
+          type="checkbox"
+          :name="`notifications_${item.key}_${header.path}_${header.key}`"
+          :disabled="context.disabled"
+          :ignore="true"
+          :label-sr-only="true"
+          :label="`${i18n.t(item.name as string)} - ${i18n.t(header.label)}`"
+          @update:model-value="
+            updateValue(
+              item.key as NotificationMatrixRowKey,
+              header.path as NotificationMatrixPathKey,
+              header.key as NotificationMatrixColumnKey,
+              $event,
+            )
+          "
+          @blur="context.handlers.blur"
+        />
+      </template>
+    </CommonSimpleTable>
+  </output>
+</template>

+ 322 - 0
app/frontend/apps/desktop/components/Form/fields/FieldNotifications/__tests__/FieldNotifications.spec.ts

@@ -0,0 +1,322 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { FormKit } from '@formkit/vue'
+import { waitFor } from '@testing-library/vue'
+import { renderComponent } from '#tests/support/components/index.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+import {
+  checkSimpleTableContent,
+  checkSimpleTableHeader,
+} from '#tests/support/components/checkSimpleTableContent.ts'
+
+const tableHeaders = [
+  'Name',
+  'My tickets',
+  'Not assigned',
+  'Subscribed tickets',
+  'All tickets',
+  'Also notify via email',
+]
+
+const tableItems = [
+  [
+    'New ticket',
+    'New ticket - My tickets',
+    'New ticket - Not assigned',
+    'New ticket - Subscribed tickets',
+    'New ticket - All tickets',
+    'New ticket - Also notify via email',
+  ],
+  [
+    'Ticket update',
+    'Ticket update - My tickets',
+    'Ticket update - Not assigned',
+    'Ticket update - Subscribed tickets',
+    'Ticket update - All tickets',
+    'Ticket update - Also notify via email',
+  ],
+  [
+    'Ticket reminder reached',
+    'Ticket reminder reached - My tickets',
+    'Ticket reminder reached - Not assigned',
+    'Ticket reminder reached - Subscribed tickets',
+    'Ticket reminder reached - All tickets',
+    'Ticket reminder reached - Also notify via email',
+  ],
+  [
+    'Ticket escalation',
+    'Ticket escalation - My tickets',
+    'Ticket escalation - Not assigned',
+    'Ticket escalation - Subscribed tickets',
+    'Ticket escalation - All tickets',
+    'Ticket escalation - Also notify via email',
+  ],
+]
+
+const testValue = {
+  create: {
+    criteria: {
+      ownedByMe: true,
+      ownedByNobody: true,
+      subscribed: true,
+      no: false,
+    },
+    channel: { email: true, online: true },
+  },
+  update: {
+    criteria: {
+      ownedByMe: true,
+      ownedByNobody: true,
+      subscribed: true,
+      no: false,
+    },
+    channel: { email: true, online: true },
+  },
+  reminderReached: {
+    criteria: {
+      ownedByMe: true,
+      ownedByNobody: false,
+      subscribed: false,
+      no: false,
+    },
+    channel: { email: true, online: true },
+  },
+  escalation: {
+    criteria: {
+      ownedByMe: true,
+      ownedByNobody: false,
+      subscribed: false,
+      no: false,
+    },
+    channel: { email: true, online: true },
+  },
+}
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+}
+
+const renderNotificationsInput = async (
+  props: Record<string, unknown> = {},
+) => {
+  const view = renderComponent(FormKit, {
+    ...wrapperParameters,
+    props: {
+      id: 'notifications',
+      type: 'notifications',
+      name: 'notifications',
+      label: 'Notifications matrix',
+      labelSrOnly: true,
+      formId: 'form',
+      ...props,
+    },
+    form: true,
+  })
+
+  await waitForNextTick(true)
+
+  return view
+}
+
+describe('Form - Field - Notifications', () => {
+  it('renders notification matrix', async () => {
+    const view = await renderNotificationsInput()
+
+    checkSimpleTableHeader(view, tableHeaders)
+    checkSimpleTableContent(view, tableItems)
+
+    const checkboxes = view.getAllByRole('checkbox')
+
+    expect(checkboxes).toHaveLength(20)
+  })
+
+  it('mutates passed value via input events', async () => {
+    const view = await renderNotificationsInput({
+      value: testValue,
+    })
+
+    const checkbox = view.getByLabelText('New ticket - My tickets')
+
+    await view.events.click(checkbox)
+
+    expect(checkbox).not.toBeChecked()
+
+    await waitFor(() => {
+      expect(view.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = view.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toStrictEqual(
+      expect.objectContaining({
+        create: expect.objectContaining({
+          criteria: expect.objectContaining({ ownedByMe: false }),
+        }),
+      }),
+    )
+  })
+})
+
+// Cover all use cases from the FormKit custom input checklist.
+//   More info here: https://formkit.com/essentials/custom-inputs#input-checklist
+describe('Fields - Notifications - Input Checklist', () => {
+  it('implements input id attribute', async () => {
+    const view = await renderNotificationsInput({
+      id: 'test_id',
+    })
+
+    expect(view.getByLabelText('Notifications matrix')).toHaveAttribute(
+      'id',
+      'test_id',
+    )
+  })
+
+  it('implements input name', async () => {
+    const view = await renderNotificationsInput({
+      name: 'test_name',
+    })
+
+    expect(view.getByLabelText('Notifications matrix')).toHaveAttribute(
+      'name',
+      'test_name',
+    )
+  })
+
+  it('implements blur handler', async () => {
+    const blurHandler = vi.fn()
+
+    const view = await renderNotificationsInput({
+      onBlur: blurHandler,
+    })
+
+    view.getByLabelText('New ticket - My tickets').focus()
+
+    await view.events.tab()
+
+    expect(blurHandler).toHaveBeenCalledOnce()
+  })
+
+  it('implements input handler', async () => {
+    const view = await renderNotificationsInput()
+
+    const checkbox = view.getByLabelText('New ticket - My tickets')
+
+    await view.events.click(checkbox)
+
+    expect(checkbox).toBeChecked()
+
+    await waitFor(() => {
+      expect(view.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = view.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toStrictEqual({
+      create: { criteria: { ownedByMe: true } },
+    })
+  })
+
+  it('implements input value display', async () => {
+    const view = await renderNotificationsInput({
+      value: testValue,
+    })
+
+    // Row 1
+    expect(view.getByLabelText('New ticket - My tickets')).toBeChecked()
+    expect(view.getByLabelText('New ticket - Not assigned')).toBeChecked()
+    expect(view.getByLabelText('New ticket - Subscribed tickets')).toBeChecked()
+    expect(view.getByLabelText('New ticket - All tickets')).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('New ticket - Also notify via email'),
+    ).toBeChecked()
+
+    // Row 2
+    expect(view.getByLabelText('Ticket update - My tickets')).toBeChecked()
+    expect(view.getByLabelText('Ticket update - Not assigned')).toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket update - Subscribed tickets'),
+    ).toBeChecked()
+
+    expect(view.getByLabelText('Ticket update - All tickets')).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket update - Also notify via email'),
+    ).toBeChecked()
+
+    // Row 3
+    expect(
+      view.getByLabelText('Ticket reminder reached - My tickets'),
+    ).toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket reminder reached - Not assigned'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket reminder reached - Subscribed tickets'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket reminder reached - All tickets'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket reminder reached - Also notify via email'),
+    ).toBeChecked()
+
+    // Row 4
+    expect(view.getByLabelText('Ticket escalation - My tickets')).toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket escalation - Not assigned'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket escalation - Subscribed tickets'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket escalation - All tickets'),
+    ).not.toBeChecked()
+
+    expect(
+      view.getByLabelText('Ticket escalation - Also notify via email'),
+    ).toBeChecked()
+  })
+
+  it('implements disabled', async () => {
+    const view = await renderNotificationsInput({
+      disabled: true,
+    })
+
+    expect(view.getByLabelText('Notifications matrix')).toBeDisabled()
+
+    const checkboxes = view.getAllByRole('checkbox')
+
+    for (const checkbox of checkboxes) {
+      expect(checkbox).toBeDisabled()
+    }
+  })
+
+  it('implements attribute passthrough', async () => {
+    const view = await renderNotificationsInput({
+      'test-attribute': 'test_value',
+    })
+
+    expect(view.getByLabelText('Notifications matrix')).toHaveAttribute(
+      'test-attribute',
+      'test_value',
+    )
+  })
+
+  it('implements standardized classes', async () => {
+    const view = await renderNotificationsInput()
+
+    expect(view.getByLabelText('Notifications matrix')).toHaveClass(
+      'formkit-input',
+    )
+  })
+})

+ 14 - 0
app/frontend/apps/desktop/components/Form/fields/FieldNotifications/index.ts

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

+ 29 - 0
app/frontend/apps/desktop/components/Form/fields/FieldNotifications/types.ts

@@ -0,0 +1,29 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+export enum NotificationMatrixRowKey {
+  Create = 'create',
+  Escalation = 'escalation',
+  ReminderReached = 'reminderReached',
+  Update = 'update',
+}
+
+export enum NotificationMatrixPathKey {
+  Criteria = 'criteria',
+  Channel = 'channel',
+}
+
+export enum NotificationMatrixColumnKey {
+  MyTickets = 'ownedByMe',
+  NotAssigned = 'ownedByNobody',
+  SubscribedTickets = 'subscribed',
+  AllTickets = 'no',
+  AlsoNotifyViaEmail = 'email',
+}
+
+export type NotificationMatrix = {
+  [rowKey in NotificationMatrixRowKey]: {
+    [pathKey in NotificationMatrixPathKey]: {
+      [columnKey in NotificationMatrixColumnKey]: boolean
+    }
+  }
+}

+ 2 - 1
app/frontend/apps/desktop/form/theme/global/getCoreDesktopClasses.ts

@@ -62,7 +62,8 @@ export const getCoreDesktopClasses: FormThemeExtension = (
       outer: 'leading-none',
       wrapper: 'inline-flex items-center cursor-pointer select-none',
       label: 'mb-0 text-sm text-gray-100 dark:text-neutral-400',
-      inner: 'w-5 h-5 flex justify-center items-center ltr:mr-1 rtl:ml-1',
+      inner:
+        'w-5 h-5 flex justify-center items-center ltr:mr-1 rtl:ml-1 formkit-label-hidden:m-0',
       input:
         'peer appearance-none focus:outline-none focus:ring-0 focus:ring-offset-0',
       decorator:

Некоторые файлы не были показаны из-за большого количества измененных файлов